UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

541 lines (489 loc) • 18.5 kB
// Provides req.data.global, an Apostrophe doc // for sitewide content such as a footer displayed on all pages. You // can also create site-wide preferences by adding schema fields. Just // configure this module with the `addFields` option as you normally would // for any widget or pieces module. // // ## options // // `deferWidgetLoading`: a performance option. if true, any widget loads that can be deferred // will be until the end of the process of loading the global doc, reducing the number of queries // for simple cases. // // Note that the `defer` option must also be set to `true` for all widget types // you wish to defer loads for. // // To avoid causing problems for routes that depend on the middleware, loads are // only deferred until the end of loading the global doc and anything it // joins with; they are not merged with deferred loads for the actual page. // This option defaults to `false` because in many cases performance is // not improved, as the global doc often contains no deferrable widgets, // or loads them efficiently already. // // `addFields`: if the schema contains fields, the "Global Content" admin bar button will // launch the editor modal for those, otherwise it will shortcut directly to the versions dialog box // which is still relevant on almost all sites because of the use of global header // and footer areas, etc. // // This module provides middleware so that `req.data.global` is always available, // even in requests that are not for Apostrophe pages. In a command line task, you can use // the provided `findGlobal` method. // // `separateWhileBusyMiddleware`: if true, the `whileBusy` method is powered // by separate middleware that checks for the lock flag in `apostrophe-global` // even if the regular middleware of this method has been disabled and/or // overridden to cache in such a way as to make it unsuitable for // this purpose. // // ## properties // // `_id`: the MongoDB ID of the global doc. Available after `modulesReady`. var async = require('async'); var Promise = require('bluebird'); var _ = require('@sailshq/lodash'); module.exports = { extend: 'apostrophe-pieces', singletonWarningIfNot: 'apostrophe-global', name: 'apostrophe-global', alias: 'global', label: 'Global', pluralLabel: 'Global', whileBusyDelay: 60, whileBusyRetryAfter: 5, whileBusyRequestDelay: 10, searchable: false, beforeConstruct: function(self, options) { options.removeFields = [ 'title', 'slug', 'tags', 'published', 'trash' ].concat(options.removeFields || []); }, afterConstruct: function(self) { self.enableMiddleware(); }, construct: function(self, options) { self.slug = options.slug || 'global'; // Fetch the `global` doc object. On success, the callback is invoked // with `(null, global)`. If no callback is passed a promise is returned. self.findGlobal = function(req, callback) { if (callback) { return body(callback); } else { return Promise.promisify(body)(); } function body(callback) { var global; var cursor; return async.series([ find, after ], function(err) { if (err) { return callback(err); } return callback(null, global); }); function find(callback) { cursor = self.find(req, { slug: self.slug }) .permission(false) .sort(false) .joins(false) .areas(false); return cursor.toObject(function(err, doc) { global = doc; // Make this available early, sans joins and area loaders, // to avoid race conditions for modules like // apostrophe-pieces-orderings-bundle if we wait // for joins that might also need the global doc to find their // default orderings, etc. req.aposGlobalCore = global; return callback(err); }); } function after(callback) { if (!global) { return callback(null); } cursor = cursor.clone(); cursor.joins(true); cursor.areas(true); return cursor.after([ global ], callback); } } }; // We no longer call initGlobal on modulesReady, we do it on the new // apostrophe:migrate event. But to maximize bc, we still register the event // handler in modulesReady. This accommodates anyone who has applied // the super pattern to that method. self.modulesReady = function(callback) { self.on('apostrophe:migrate', 'initGlobalPromisified', function(options) { return Promise.promisify(self.initGlobal)(); }); return callback(null); }; // Initialize the `global` doc, if necessary. Invoked late in the // startup process by `modulesReady`. self.initGlobal = function(callback) { var req = self.apos.tasks.getReq(); var existing; return async.series({ // Early in pre-2.0 code there was no type property for the global page. // We can't use a standard migration to fix that because initGlobal // is called too soon migrate: function(callback) { return self.apos.docs.db.update({ slug: self.slug, $or: [ // Could still be literally undefined, or it could have been // patched by the orphan doc type creator { type: { $exists: 0 } }, { type: 'WASUNDEFINED' } ] }, { $set: { type: self.name } }, callback); }, fetch: function(callback) { // Existence test must not load widgets etc. as this can lead // to chicken and egg problems if widgets join with page types // not yet registered. // // There can be more than one document if workflow is present. return self.apos.docs.db.find({ slug: self.slug }).toArray(function(err, result) { if (err) { return callback(err); } existing = result; return callback(); }); }, insert: function(callback) { if (existing.length) { return setImmediate(callback); } var insert = self.newInstance(); Object.assign(insert, { slug: self.slug, published: true }); return self.apos.docs.insert(req, insert, callback); }, update: function(callback) { // Add missing fields based on their def properties const sample = self.newInstance(); return async.eachSeries(existing, function(doc, callback) { let modified = false; _.each(self.schema, function(field) { if ((!_.has(doc, field.name)) && (_.has(sample, field.name))) { doc[field.name] = sample[field.name]; modified = true; } }); if (modified) { var eReq = req; if (doc.workflowLocale) { // Make sure the request matches the locale eReq = Object.assign({}, req, { locale: doc.workflowLocale }); } return self.apos.docs.update(eReq, doc, callback); } else { return setImmediate(callback); } }, callback); } }, function(err) { if (err) { return callback(err); } // Populated for bc, we now get the _id from the copy fetched // per request by the middleware self._id = existing._id; return callback(null); }); }; // Add the `addGlobalToData` middleware. And if requested, // the separate middleware for checking the global busy flag // when addGlobalToData has been overridden in a way that might // involve caching or otherwise not be up to date at all times. self.enableMiddleware = function() { self.expressMiddleware = (self.options.separateWhileBusyMiddleware ? [ self.whileBusyMiddleware ] : []).concat([ self.addGlobalToData ]); }; self.whileBusyMiddleware = function(req, res, next) { var _global; return async.series([ find, check ], function(err) { if (err) { self.apos.utils.error(err); return res.status(500).send('error'); } return next(); }); function find(callback) { return self.find(req, { slug: self.slug }) .permission(false) .areas(false) .joins(false) .toObject(function(err, __global) { if (err) { return res.status(500).send('error'); } _global = __global; return callback(null); }); } function check(callback) { return self.checkWhileBusy(req, _global, callback); } }; self.checkWhileBusy = function(req, _global, callback) { var lockName = 'apostrophe-global-busy'; var localeLockName; var propName = 'globalBusy'; var localePropName; if (req.locale) { localeLockName = lockName + '-' + req.locale; localePropName = propName + req.locale; } return async.series([ considerGlobal, considerLocale ], callback); function considerGlobal(callback) { return considerLock(propName, lockName, callback); } function considerLocale(callback) { if (!localePropName) { return callback(null); } return considerLock(localePropName, localeLockName, callback); } function considerLock(propName, lockName, callback) { if (_global[propName]) { return self.apos.locks.lock(lockName, { wait: (req.res && req.res.send) ? self.options.whileBusyRequestDelay * 1000 : Number.MAX_VALUE, waitForSelf: true }, function(err) { if (!err) { // We got in after a period of the system being busy, // or the process with the lock went away. Either way, // we should clear globalBusy and give up our lock, // then continue normal operation. return async.series([ unmark, unlock, refind ], callback); } // An error occurred. If it is because the lock is still // present after waiting as long as we could for this req, // send a try-again response to the user. if ((err === 'locked') && req.res && req.res.send) { return self.busyTryAgainSoon(req); } else { // All other errors propagate normally return callback(err); } }); } return callback(null); function unmark(callback) { var $set = {}; $set[propName] = false; return self.apos.docs.db.update({ _id: _global._id }, { $set: $set }, callback); } function unlock(callback) { return self.apos.locks.unlock(lockName, callback); } function refind(callback) { return self.findGlobal(req, function(err, result) { if (err) { return callback(err); } req.data.global = result; return callback(null); }); } } }; // Fetch the global doc and add it to `req.data` as `req.data.global`, if it // is not already present. If it is already present, skip the // extra query. // // If called with three arguments, acts as middleware. // // If called with two arguments, the first is `req` and the second is // invoked as `callback`. // // If called with one argument, that argument is `req` and a promise // is returned. self.addGlobalToData = function(req, res, next) { if (arguments.length >= 3) { // middleware return body(function(err) { if (err) { self.apos.utils.error('ERROR loading global doc: ', err); // Here in middleware we can't tell if this would have been a page, // so we can't set `aposError` and wait. We have to force the issue res.status(500).send('error'); return; } // Don't break other middleware or routes that might not be defer-aware. // The regular page rendering route will reenable this on its own req.deferWidgetLoading = false; return next(); }); } else if (arguments.length === 2) { // res is actually callback return body(res); } else { // just takes req and returns promise return Promise.promisify(body)(); } function body(callback) { if (req.data.global) { return callback(null); } if (self.options.deferWidgetLoading) { req.deferWidgetLoading = true; } return async.series([ findGlobal, check, loadDeferredWidgets ], callback); } function findGlobal(callback) { return self.findGlobal(req, function(err, result) { if (err) { return callback(err); } req.data.global = result; return callback(null); }); } function check(callback) { return self.checkWhileBusy(req, req.data.global, callback); } function loadDeferredWidgets(callback) { if (!self.options.deferWidgetLoading) { return callback(null); } return self.apos.areas.loadDeferredWidgets(req, callback); } }; self.busyTryAgainSoon = function(req) { if (!_.includes([ 'GET', 'HEAD' ], req.method)) { // Typically an API will receive this sad news return req.res.status(503).send({ status: 'busy' }); } return req.res.status(503).set('Refresh', self.options.whileBusyRetryAfter).send(self.render(req, 'busy.html')); }; // Run the given function while the entire site is marked as busy. // // This is a promise-based method. `fn` may return a promise, which will // be awaited. This method will return a promise, which must be awaited. // // While the site is busy new requests are delayed as much as possible, // then GET requests receive a simple "busy" page that retries // after an interval, etc. To address the issue of requests already // in progress, this method marks the site busy, then waits for // `options.whileBusyDelay` seconds before invoking `fn`. // That option defaults to 60 (one minute). Explicitly tracking // all requests in flight would have too much performance impact // on normal operation. // // This method should be used very rarely, for instance for a procedure // that deploys an entirely new set of content to the site. Use of // this method for anything more routine would have a crippling // performance impact. // // **Use with workflow**: if `options.locale` argument is present, only // the given locale name is marked busy. If `req` has any other // `req.locale` it proceeds normally. This option works only with // 'apostrophe-workflow' (the global docs must have `workflowLocale` // properties). self.whileBusy = function(fn, options) { var locked = false; var marked = false; var lockName = 'apostrophe-global-busy'; var propName = 'globalBusy'; var locale = options && options.locale; if (locale) { lockName += '-' + locale; propName += locale; } var criteria = { type: self.name }; if (locale) { criteria.workflowLocale = locale; } return Promise.try(function() { return self.apos.locks.lock(lockName); }).then(function() { locked = true; var $set = {}; $set[propName] = true; var args = { $set: $set }; return self.apos.docs.db.update(criteria, args, { // so that if we really want to lock across all locales // (locale not passed and workflow present), we can multi: true }); }).then(function() { marked = true; return Promise.delay(self.options.whileBusyDelay * 1000); }).then(function() { return fn(); }).finally(function() { return Promise.try(function() { var $set = {}; $set[propName] = false; var args = { $set: $set }; if (marked) { return self.apos.docs.db.update(criteria, args, { // workflow-aware for cases where we really do want // to lock across all locales multi: true }); } }).then(function() { if (locked) { return self.apos.locks.unlock(lockName); } }); }); }; var superGetCreateSingletonOptions = self.getCreateSingletonOptions; self.getCreateSingletonOptions = function(req) { var browserOptions = superGetCreateSingletonOptions(req); // For compatibility with the workflow module try to get the _id // from the copy the middleware fetched for this specific request, // if not fall back to self._id browserOptions._id = (req.data.global && req.data.global._id) || self._id; return browserOptions; }; // There is only one useful object of this type, so having access to the admin // bar button is not helpful unless you can edit that one, rather than // merely creating a new one (for which there is no UI). Thus we need // to set the permission requirement to admin-apostrophe-global. // This is called for you. self.addToAdminBar = function() { self.apos.adminBar.add(self.__meta.name, self.pluralLabel, 'admin-' + self.name); }; var superGetEditControls = self.getEditControls; self.getEditControls = function(req) { var controls = superGetEditControls(req); var more = _.find(controls, { name: 'more' }); if (more) { more.items = _.reject(more.items, function(item) { return _.contains([ 'trash', 'copy' ], item.action); }); } return controls; }; } };