apostrophe-pages
Version:
Adds trees of pages to the Apostrophe content management system
1,388 lines (1,276 loc) • 80.4 kB
JavaScript
var async = require('async');
var _ = require('underscore');
var extend = require('extend');
var path = require('path');
RegExp.quote = require('regexp-quote');
module.exports = function(options, callback) {
return new pages(options, callback);
};
function pages(options, callback) {
var apos = options.apos;
var app = options.app;
var self = this;
var aposPages = this;
self._action = '/apos-pages';
// Usage: app.get('*', pages.serve({ typePath: __dirname + '/views/pages' }))
//
// If you use this global wildcard route, make it your LAST route,
// as otherwise it overrides everything else.
//
// If you want to mount your pages as a "subdirectory:"
//
// app.get('/pages/*', pages.serve({ ... }))
//
// You can use other route patterns, as long as req.params[0] contains the
// page slug.
//
// self.serve will automatically prepend a / to the slug if
// req.params[0] does not contain one.
//
// The page object is passed to the Nunjucks type as `page`.
//
// If you want to also load all areas on the "global" page, for instance
// to fetch shared headers and footers used across a site, supply a
// `load` callback:
//
// app.get('/pages/*', pages.serve({ load: [ 'global' ] }, ...))
//
// The page with the slug `global` then becomes visible to the Nunjucks
// type as `global`. Note that it may not exist yet, in which case
// `global` is not set. Your type code must allow for this.
//
// You can include functions in the load: array. If you do so, those
// functions are invoked as callbacks, and receive 'req' as their first
// parameter. They should add additional page objects as properties of the
// req.extras object, then invoke the callback they receive as their
// second parameter with null, or with an error if they have failed in a
// way that should result in a 500 error. All such extra pages are made
// visible to Nunjucks. For instance, if you load req.extras.department,
// then a variable named department containing that page is visible to Nunjucks.
//
// It is is also acceptable to pass a single function rather than an
// array as the `load` property.
//
// The type name used to render the page is taken from
// the type property of the req.page object. You will need to set the
// directory from which page type templates are loaded:
//
// app.get('*', pages.serve({ typePath: __dirname + '/views/pages' })
//
// You can also override individual type paths. Any paths you don't
// override continue to respect typePath. Note that you are still
// specifying a folder's path, which must contain a nunjucks type
// named home.html to render a page with that type property:
//
// app.get('*', pages.serve({ ..., typePaths: { home: __dirname + '/views/pages' } })
//
// In the event the page slug requested is not found, the notfound type
// is rendered. You can override the notfound type path like any other.
//
// Loaders can access the page loaded by `page.serve` as `req.page`. This will
// be null if no page slug matched the URL exactly. However, if there is a page
// that matches a leading portion of the URL when followed by `/`, that page
// is also made available as `req.bestPage`. In this case the remainder of the
// URL after the slug of the best page is returned as `req.remainder`. If more
// than one page partially matches the URL the longest match is provided.
//
// Loaders can thus implement multiple-page experiences of almost any complexity
// by paying attention to `req.remainder` and choosing to set `req.type` to
// something that suits their purposes. If `req.type` is set by a loader it is
// used instead of the original type of the page to select a template. Usually this
// process begins by examining `req.bestPage.type` to determine whether it is suitable
// for this treatment (a blog page, for example, might need to implement virtual
// subpages for articles in this way).
//
// Loaders can also set req.page to req.bestPage, and should do so when electing
// to accept a partial match, because this makes the page available to templates.
//
// Page type templates will want to render areas, passing along the slug and the
// edit permission flag:
//
// {{ aposArea({ slug: slug + ':main', area: page.main, edit: edit }) }}
//
// {{ aposArea({ slug: 'global:footer', area: global.footer, edit: edit }) }}
//
// You can access all properties of the page via the 'page' object. Any pages
// added to extras by `load` callbacks are also visible, like `global` above.
//
// If you want to create pages dynamically when nonexistent page slugs are visited,
// you can supply a notfound handler:
//
// // Just create an empty page object like a wiki would
// app.get('*', pages.serve({
// notfound: function(req, callback) {
// req.page = { areas: {} };
// callback(null);
// }
// });
//
// If you do not set req.page the normal page-not-found behavior is applied.
// Make sure you specify at least an areas property. If you do not supply a
// type property, 'default' is assumed.
//
// A JSON interface is also built in for each page: if you add
// ?pageInformation=json to the URL, a JSON description of the page
// is returned, including any information added to the page object
// by loader functions. This is available only to users with
// editing permissions.
self.serve = function(options) {
if(!options) {
options = {};
}
_.defaults(options, {
root: ''
});
return function(req, res) {
function now() {
var time = process.hrtime();
return time[0] + (time[1] / 1000000000.0);
}
function time(fn, name) {
return function(callback) {
var start = now();
return fn(function(err) {
// console.log(name + ': ' + (now() - start));
return callback(err);
});
};
}
var start = now();
// Express doesn't provide the absolute URL the user asked for by default.
// TODO: move this to middleware for even more general availability in Apostrophe.
// See: https://github.com/visionmedia/express/issues/1377
if (!req.absoluteUrl) {
req.absoluteUrl = req.protocol + '://' + req.get('Host') + req.url;
}
req.extras = {};
return async.series([time(page, 'page'), time(permissions, 'permissions'), time(relatives, 'relatives'), time(load, 'load'), time(notfound, 'notfound')], main);
function page(callback) {
// Get content for this page
req.slug = req.params[0];
if ((!req.slug.length) || (req.slug.charAt(0) !== '/')) {
req.slug = '/' + req.slug;
}
apos.getPage(req, req.slug, function(e, page, bestPage, remainder) {
if (e) {
return callback(e);
}
// Set on exact slug matches only
// "What if there is no page?" We'll note that later
// and send the 404 type. We still want to load all
// the global stuff first
req.page = page;
// Set on partial slug matches followed by a / and on
// exact matches as well
req.bestPage = bestPage;
// Set to the empty string on exact matches, otherwise
// to the portion of the URL after the slug of req.bestPage. Note
// that any trailing / has already been removed. A leading
// / is always present, even if the page is the home page.
req.remainder = remainder;
if (req.bestPage) {
req.bestPage.url = options.root + req.bestPage.slug;
}
return callback(null);
});
}
function permissions(callback) {
// 404 in progress
if (!req.bestPage) {
return callback(null);
}
// Are we cool enough to view and/or edit this page?
async.series([checkView, checkEdit], callback);
function checkView(callback) {
return apos.permissions(req, 'view-page', req.bestPage, function(err) {
// If there is a permissions error then note that we are not
// cool enough to see the page, which triggers the appropriate
// error type.
if (err) {
if (req.user) {
req.insufficient = true;
} else {
req.loginRequired = true;
}
}
return callback(null);
});
}
function checkEdit(callback) {
return apos.permissions(req, 'edit-page', req.bestPage, function(err) {
// If there is no permissions error then note that we are cool
// enough to edit the page
req.edit = !err;
return callback(null);
});
}
}
function relatives(callback) {
if(!req.bestPage) {
return callback(null);
}
async.series([
function(callback) {
// If you want tabs you also get ancestors, so that
// the home page is available (the parent of tabs).
if (options.ancestors || options.tabs || true) {
return self.getAncestors(req, req.bestPage, options.ancestorCriteria || {}, options.ancestorOptions || {}, function(err, ancestors) {
req.bestPage.ancestors = ancestors;
if (ancestors.length) {
// Also set parent as a convenience
req.bestPage.parent = req.bestPage.ancestors.slice(-1)[0];
}
return callback(err);
});
} else {
return callback(null);
}
},
function(callback) {
if (options.peers || true) {
var ancestors = req.bestPage.ancestors;
if (!ancestors.length) {
// The only peer of the homepage is itself.
//
// Avoid a circular reference that crashes
// extend() later when we try to pass the homepage
// as the .permalink option to a loader. This
// happens if the homepage is a blog.
var selfAsPeer = {};
extend(true, selfAsPeer, req.bestPage);
req.bestPage.peers = [ selfAsPeer ];
return callback(null);
}
var parent = ancestors[ancestors.length - 1];
self.getDescendants(req, parent, options.tabOptions || {}, function(err, pages) {
req.bestPage.peers = pages;
return callback(err);
});
} else {
return callback(null);
}
},
function(callback) {
if (options.descendants || true) {
return self.getDescendants(req, req.bestPage, options.descendantCriteria || {}, options.descendantOptions || {}, function(err, children) {
req.bestPage.children = children;
return callback(err);
});
} else {
return callback(null);
}
},
function(callback) {
if (options.tabs || true) {
self.getDescendants(req, req.bestPage.ancestors[0] ? req.bestPage.ancestors[0] : req.bestPage, options.tabCriteria || {}, options.tabOptions || {}, function(err, pages) {
req.bestPage.tabs = pages;
return callback(err);
});
} else {
return callback(null);
}
}
], callback);
}
function load(callback) {
// Get any shared pages like global footers, also
// invoke load callbacks if needed
var loadList = options.load ? options.load : [];
// Be tolerant if they pass just one function
if (typeof(loadList) === 'function') {
loadList = [ loadList ];
}
// Turn any slugs into callbacks to fetch those slugs.
// This is a little lazy: if we turn out to need multiple
// pages of shared stuff we could coalesce them into a
// single mongo query. However we typically don't, or
// we're loading some of them only in certain situations.
// So let's not prematurely optimize
loadList = loadList.map(function(item) {
if (typeof(item) !== 'function') {
return function(callback) {
// Hardcoded slugs of virtual pages to be loaded for every user every time
// imply we're not concerned with permissions. Avoiding them saves us the
// hassle of precreating pages like "global" just to set published: true etc.
apos.getPage(req, item, { permissions: false }, function(err, page) {
if (err) {
return callback(err);
}
// Provide an object with an empty areas property if
// the page doesn't exist yet. This simplifies page type templates
// The new syntax for aposArea() requires a more convincing fake page!
// Populate slug and permissions correctly
req.extras[item] = page ? page : { slug: item, areas: [] };
if (!page) {
apos.addPermissionsToPages(req, [req.extras[item]]);
}
return callback(null);
});
};
} else {
// Already a callback, now wrap it in a function that can
// see the req variable
return function(callback) {
return item(req, callback);
};
}
});
return async.parallel(loadList, callback);
}
function notfound(callback) {
// Implement the automatic redirect mechanism for pages whose
// slugs have changed, unless an alternate mechanism has been specified
if (!req.page) {
if (options.notfound) {
return options.notfound(req, function(err) {
return callback(err);
});
} else {
// Check for a redirect from an old slug before giving up
apos.redirects.findOne({from: req.slug }, function(err, redirect) {
if (redirect) {
return res.redirect(options.root + redirect.to);
} else {
return callback(null);
}
});
}
} else {
return callback(null);
}
}
function main(err) {
var providePage = true;
// Rendering errors isn't much different from
// rendering other stuff. We still get access
// to shared stuff loaded via `load`.
// If the load functions already picked a type respect it,
// whether it is on the allowed list for manual type choices
// or not. Otherwise implement standard behaviors
// pages.serve treats the request object as a repository of everything
// we know about this request so far, including simple hints about the
// desired response. This is different from the default paradigm
// of Express.
if (req.contentType) {
res.setHeader('Content-Type', req.contentType);
}
if (req.redirect) {
return res.redirect(req.redirect);
}
if (req.notfound) {
// A loader asked us to 404
res.statusCode = 404;
req.template = 'notfound';
providePage = false;
} else if (!req.template) {
if (err) {
console.log(err);
req.template = 'serverError';
res.statusCode = 500;
providePage = false;
} else if (req.loginRequired) {
req.template = 'loginRequired';
providePage = false;
} else if (req.insufficient) {
req.template = 'insufficient';
providePage = false;
} else if (req.page) {
// Make sure the type is allowed
req.template = req.page.type;
if (_.some(aposPages.types, function(item) {
return item.name === req.type;
})) {
req.template = 'default';
}
} else {
res.statusCode = 404;
req.template = 'notfound';
providePage = false;
}
}
if (req.template === undefined) {
// Supply a default template name
req.template = 'default';
}
if (providePage) {
req.pushData({
aposPages: {
// Prune the page back so we're not sending everything
// we know about every event in every widget etc., which
// is redundant and results in slow page loads and
// high bandwidth usage
page: apos.prunePage(req.bestPage)
}
});
}
var args = {
edit: req.edit,
slug: providePage ? req.bestPage.slug : null,
page: providePage ? req.bestPage : null
};
_.extend(args, req.extras);
// A simple way to access everything we know about the page
// in JSON format. Allow this only if we have editing privileges
// on the page.
if ((req.query.pageInformation === 'json') && args.page && (args.page._edit)) {
return res.send(args.page);
}
var path;
if (typeof(req.template) === 'string') {
path = __dirname + '/views/' + req.template;
if (options.templatePath) {
path = options.templatePath + '/' + req.template;
}
if (options.templatePaths) {
if (options.templatePaths[type]) {
path = options.templatePaths[type] + '/' + req.template;
}
}
}
return res.send(self.renderPage(req, path ? path : req.template, args));
}
};
};
// Fetch ancestors of the specified page. We need req to
// determine permissions. Normally areas associated with
// ancestors are not returned. If you specify options.areas as
// `true`, all areas will be returned. If you specify options.areas
// as an array of area names, areas in that list will be returned.
//
// You may use options.getOptions to pass additional options
// directly to apos.get, notably trash: 'any' for use when
// implementing reorganize, trashcan, etc.
//
// You may use the criteria parameter to directly specify additional
// MongoDB criteria ancestors must match to be returned.
//
// You may skip the criteria and options arguments.
self.getAncestors = function(req, page, criteriaArg, options, callback) {
if (arguments.length === 4) {
callback = arguments[3];
options = arguments[2];
criteriaArg = {};
}
if (arguments.length === 3) {
callback = arguments[2];
criteriaArg = {};
options = {};
}
_.defaults(options, {
root: ''
});
var paths = [];
// Pages that are not part of the tree and the home page of the tree
// have no ancestors
if ((!page.path) || (page.path.indexOf('/') === -1)) {
return callback(null, paths);
}
var components = page.path.split('/');
var path = '';
_.each(components, function(component) {
path += component;
// Don't redundantly load ourselves
if (path === page.path) {
return;
}
paths.push(path);
path += '/';
});
var getOptions = {
fields: {
lowSearchText: 0, highSearchText: 0, searchSummary: 0
},
sort: {
path: 1
}
};
if (options.areas) {
getOptions.areas = options.areas;
} else {
getOptions.fields.areas = 0;
}
if (options.getOptions) {
extend(true, getOptions, options.getOptions);
}
var criteria = {
$and: [
{ path: { $in: paths } },
criteriaArg
]
};
// Get metadata about the related pages, skipping expensive stuff.
// Sorting by path works because longer strings sort
// later than shorter prefixes
return apos.get(req, criteria, getOptions, function(err, results) {
if (err) {
return callback(err);
}
var pages = results.pages;
_.each(pages, function(page) {
page.url = options.root + page.slug;
});
return callback(null, pages);
});
};
// We need req to determine permissions
self.getParent = function(req, page, options, callback) {
if (arguments.length === 3) {
callback = arguments[2];
options = {};
}
return self.getAncestors(req, page, options, function(err, ancestors) {
if (err) {
return callback(err);
}
if (!ancestors.length) {
return callback(null);
}
return callback(null, ancestors[ancestors.length - 1]);
});
};
// The `trash` option controls whether pages with the trash flag are
// included. If true, only trash is returned. If false, only non-trash
// is returned. If null, both are returned. false is the default.
//
// The `orphan` option works the same way. `orphan` pages are
// normally accessible, but are snot shown in subnav, tabs, etc., so
// this is the only method for which `orphan` defaults to false.
//
// Normally areas associated with ancestors are not returned.
// If you specify `options.areas` as `true`, all areas will be returned.
// If you specify `options.areas` as an array of area names, areas on that
// list will be returned.
//
// Specifying options.depth = 1 fetches immediate children only.
// You may specify any depth. The default depth is 1.
//
// You may also pass arbitrary mongodb criteria as the criteria parameter.
//
// You may skip the criteria argument, or both criteria and options.
self.getDescendants = function(req, ofPage, criteriaArg, optionsArg, callback) {
if (arguments.length === 4) {
callback = arguments[3];
optionsArg = arguments[2];
criteriaArg = {};
}
if (arguments.length === 3) {
callback = arguments[2];
optionsArg = {};
criteriaArg = {};
}
var options = {};
extend(true, options, optionsArg);
_.defaults(options, {
root: ''
});
if (options.orphan === undefined) {
options.orphan = false;
}
var depth = options.depth;
// Careful, let them specify a depth of 0 but still have a good default
if (depth === undefined) {
depth = 1;
}
var criteria = {
$and: [
{
path: new RegExp('^' + RegExp.quote(ofPage.path + '/')),
level: { $gt: ofPage.level, $lte: ofPage.level + depth }
}, criteriaArg
]
};
// Skip expensive things
options.fields = { lowSearchText: 0, highSearchText: 0, searchSummary: 0 };
if (!options.areas) {
// Don't fetch areas at all unless we're interested in a specific
// subset of them
options.fields.areas = 0;
}
options.sort = { level: 1, rank: 1 };
apos.get(req, criteria, options, function(err, results) {
if (err) {
return callback(err);
}
var pages = results.pages;
var children = [];
var pagesByPath = {};
_.each(pages, function(page) {
page.children = [];
page.url = options.root + page.slug;
pagesByPath[page.path] = page;
var last = page.path.lastIndexOf('/');
var parentPath = page.path.substr(0, last);
if (pagesByPath[parentPath]) {
pagesByPath[parentPath].children.push(page);
} else if (page.level === (ofPage.level + 1)) {
children.push(page);
} else {
// The parent of this page is hidden from us, so we shouldn't
// include this page in the results as viewed from here
}
});
return callback(null, children);
});
};
// Get all pages that have the mentioned tag. 'options' parameter, if present,
// may contain an 'areas' flag indicating that the content areas should be returned,
// otherwise only metadata is returned. Pages are sorted by rank, which is helpful
// if you are using tags to display a subset of child pages and wish to preserve their
// natural order. Pages are not returned in a tree structure, pages of any level
// may appear anywhere in the result
self.getByTag = function(req, tag, options, callback) {
return self.getByTags(req, [tag], options, callback);
};
// Get all pages that have at least one of the mentioned tags. 'options' parameter,
// if present, may contain an 'areas' flag indicating that the content areas should
// be returned, otherwise only metadata is returned.
//
// Pages are sorted by rank, which is helpful if you are using tags to display a subset
// of child pages and wish to preserve their natural order. Pages are not returned in a tree
// structure, pages of any level may appear anywhere in the result
self.getByTags = function(req, tags, options, callback) {
if (!callback) {
callback = options;
options = {};
}
var projection;
if (options.areas) {
projection = {};
} else {
projection = { areas: 0 };
}
var criteria = { path: { $exists: 1 }, tags: { $in: tags }};
return apos.get(req, criteria, {}, function(err, results) {
if (err) {
return callback(err);
}
var pages;
if (results) {
pages = results.pages;
}
return callback(null, results);
});
};
// Filter the pages array to those pages that have the specified tag. Returns directly,
// has no callback
self.filterByTag = function(pages, tag) {
return self.filterByTags(pages, [tag]);
};
// Filter the pages array to those pages that have at least one of the specified tags.
// Returns directly, has no callback
self.filterByTags = function(pages, tags) {
return _.filter(pages, function(page) {
return page.tags && _.some(page.tags, function(tag) {
return _.contains(tags, tag);
});
});
};
// position can be 'before', 'after' or 'inside' and determines the
// moved page's new relationship to the target page. You may pass
// page objects instead of slugs if you have them. The callback
// receives an error and, if there is no error, also an array of
// objects with _id and slug properties, indicating the new slugs
// of all modified pages
self.move = function(req, movedSlug, targetSlug, position, callback) {
var moved, target, parent, oldParent, changed = [];
if (typeof(movedSlug) === 'object') {
moved = movedSlug;
}
if (typeof(targetSlug) === 'object') {
target = targetSlug;
}
var rank;
var originalPath;
var originalSlug;
async.series([getMoved, getTarget, getOldParent, getParent, permissions, nudgeNewPeers, moveSelf, moveDescendants, trashDescendants ], finish);
function getMoved(callback) {
if (moved) {
return callback(null);
}
if (movedSlug.charAt(0) !== '/') {
return fail();
}
apos.pages.findOne({ slug: movedSlug }, function(err, page) {
if (!page) {
return callback('no such page');
}
moved = page;
if (!moved.level) {
return callback('cannot move root');
}
// You can't move the trashcan itself, but you can move its children
if (moved.trash && (moved.level === 1)) {
return callback('cannot move trashcan');
}
return callback(null);
});
}
function getTarget(callback) {
if (target) {
return callback(null);
}
if (targetSlug.charAt(0) !== '/') {
return fail();
}
apos.pages.findOne({ slug: targetSlug }, function(err, page) {
if (!page) {
return callback('no such page');
}
target = page;
if ((target.trash) && (target.level === 1) && (position === 'after')) {
return callback('trash must be last');
}
return callback(null);
});
}
function getOldParent(callback) {
self.getParent(req, moved, { getOptions: { permissions: false, trash: 'any' } }, function(err, parentArg) {
oldParent = parentArg;
return callback(err);
});
}
function getParent(callback) {
if (position === 'inside') {
parent = target;
rank = 0;
return callback(null);
}
if (position === 'before') {
rank = target.rank;
if (rank >= 1000000) {
// It's legit to move a page before search or trash, but we
// don't want its rank to wind up in the reserved range. Find
// the rank of the next page down and increment that.
return self.pages.find({ slug: /^\//, path: /^home\/[^\/]$/ }, { rank: 1 }).sort({ rank: -1 }).limit(1).toArray(function(err, pages) {
if (err) {
return callback(err);
}
if (!pages.length) {
rank = 1;
return callback(null);
}
rank = pages[0].rank;
return callback(null);
});
}
} else if (position === 'after') {
if (target.rank >= 1000000) {
// Reserved range
return callback('cannot move a page after a system page');
}
rank = target.rank + 1;
} else {
return callback('no such position option');
}
self.getParent(req, target, { getOptions: { permissions: false, trash: 'any' } }, function(err, parentArg) {
if (!parentArg) {
return callback('cannot create peer of home page');
}
parent = parentArg;
return callback(null);
});
}
function permissions(callback) {
apos.permissions(req, 'manage-page', parent, function(err) {
if (err) {
return callback(err);
}
apos.permissions(req, 'manage-page', moved, callback);
});
}
// This results in problems if the target is below us, and
// it really doesn't matter if there are gaps between ranks. -Tom
//
// function nudgeOldPeers(callback) {
// // Nudge up the pages that used to follow us
// // Leave reserved range alone
// var oldParentPath = path.dirname(moved.path);
// apos.pages.update({ path: new RegExp('^' + RegExp.quote(oldParentPath + '/')), level: moved.level, rank: { $gte: moved.rank, $lte: 1000000 }}, { $inc: { rank: -1 } }, { multi: true }, function(err, count) {
// return callback(err);
// });
// }
function nudgeNewPeers(callback) {
// Nudge down the pages that should now follow us
// Leave reserved range alone
// Always remember multi: true
apos.pages.update({ path: new RegExp('^' + RegExp.quote(parent.path + '/')), level: parent.level + 1, rank: { $gte: rank, $lte: 1000000 } }, { $inc: { rank: 1 } }, { multi: true }, function(err, count) {
return callback(err);
});
}
function moveSelf(callback) {
originalPath = moved.path;
originalSlug = moved.slug;
var level = parent.level + 1;
var newPath = parent.path + '/' + path.basename(moved.path);
// We're going to use update with $set, but we also want to update
// the object so that moveDescendants can see what we did
moved.path = newPath;
// If the old slug wasn't customized, update the slug as well as the path
if (parent._id !== oldParent._id) {
var matchOldParentSlugPrefix = new RegExp('^' + RegExp.quote(apos.addSlashIfNeeded(oldParent.slug)));
if (moved.slug.match(matchOldParentSlugPrefix)) {
var slugStem = parent.slug;
if (slugStem !== '/') {
slugStem += '/';
}
moved.slug = moved.slug.replace(matchOldParentSlugPrefix, apos.addSlashIfNeeded(parent.slug));
changed.push({
_id: moved._id,
slug: moved.slug
});
}
}
moved.level = level;
moved.rank = rank;
// Are we in the trashcan? Our new parent reveals that
if (parent.trash) {
moved.trash = true;
} else {
delete moved.trash;
}
apos.putPage(req, originalSlug, moved, function(err, page) {
moved = page;
return callback(null);
});
}
function moveDescendants(callback) {
return self.updateDescendantPathsAndSlugs(moved, originalPath, originalSlug, function(err, changedArg) {
if (err) {
return callback(err);
}
changed = changed.concat(changedArg);
return callback(null);
});
}
function trashDescendants(callback) {
// Make sure our descendants have the same trash status
var matchParentPathPrefix = new RegExp('^' + RegExp.quote(moved.path + '/'));
var $set = {};
var $unset = {};
if (moved.trash) {
$set.trash = true;
} else {
$unset.trash = true;
}
apos.pages.update({ path: matchParentPathPrefix }, { $set: $set, $unset: $unset }, callback);
}
function finish(err) {
if (err) {
return callback(err);
}
return callback(null, changed);
}
};
/**
* Update the paths and slugs of descendant pages, changing slugs only if they were
* compatible with the original slug. On success, invokes callback with
* null and an array of objects with _id and slug properties, indicating
* the new slugs for any objects that were modified.
* @param {page} page
* @param {string} originalPath
* @param {string} originalSlug
* @param {Function} callback
*/
self.updateDescendantPathsAndSlugs = function(page, originalPath, originalSlug, callback) {
// If our slug changed, then our descendants' slugs should
// also change, if they are still similar. You can't do a
// global substring replace in MongoDB the way you can
// in MySQL, so we need to fetch them and update them
// individually. async.mapSeries is a good choice because
// there may be zillions of descendants and we don't want
// to choke the server. We could use async.mapLimit, but
// let's not get fancy just yet
var changed = [];
if ((originalSlug === page.slug) && (originalPath === page.path)) {
return callback(null, changed);
}
var oldLevel = originalPath.split('/').length - 1;
var matchParentPathPrefix = new RegExp('^' + RegExp.quote(originalPath + '/'));
var matchParentSlugPrefix = new RegExp('^' + RegExp.quote(originalSlug + '/'));
var done = false;
var cursor = apos.pages.find({ path: matchParentPathPrefix }, { slug: 1, path: 1, level: 1 });
return async.whilst(function() { return !done; }, function(callback) {
return cursor.nextObject(function(err, desc) {
if (err) {
return callback(err);
}
if (!desc) {
// This means there are no more objects
done = true;
return callback(null);
}
var newSlug = desc.slug.replace(matchParentSlugPrefix, page.slug + '/');
changed.push({
_id: desc._id,
slug: newSlug
});
apos.pages.update({ _id: desc._id }, { $set: {
// Always matches
path: desc.path.replace(matchParentPathPrefix, page.path + '/'),
// Might not match, and we don't care (if they edited the slug that far up,
// they did so intentionally)
slug: newSlug,
level: desc.level + (page.level - oldLevel)
}}, callback);
});
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, changed);
});
};
// Accepts page objects and filters them to those that the
// current user is permitted to view
self.filterByView = function(req, pages, callback) {
async.filter(pages, function(page, callback) {
return apos.permissions(req, 'view-page', page, function(err) {
return callback(!err);
});
}, function(pages) {
return callback(null, pages);
});
};
// Return a page type object if one was configured for the given type name.
// JavaScript doesn't iterate over object properties in a defined order,
// so we maintain the list of types as a flat array. This convenience method
// prevents this from being inconvenient and allows us to choose to do more
// optimization later.
self.getType = function(name) {
return _.find(aposPages.types, function(item) {
return item.name === name;
});
};
// Add a page type.
//
// The simplest type you can pass in is an object with name and label properties.
// That enables a custom page template in your views folder. You can do a lot more
// than that, though; see apostrophe-snippets for the basis from which blogs and
// events are both built.
//
// To simplify the creation of the page types menu, you may push a type more than
// once under the same name: usually the first time with just the name and label,
// and the second time with a complete page type manager object, as when initializing
// the blog module. The last version added wins.
self.addType = function(type) {
var found = false;
var i;
for (i = 0; (i < self.types.length); i++) {
if (self.types[i].name === type.name) {
self.types[i] = type;
return;
}
}
self.types.push(type);
apos.pushGlobalCallWhen('user', 'aposPages.addType(?)', { name: type.name, label: type.label });
};
// Get the index type objects corresponding to an instance or the name of an
// instance type. Instance types are a relevant concept for snippet pages,
// blog pages, event calendar pages, etc. and everything derived from them.
//
// In this pattern "instance" pages, like individual blogPosts, are outside
// of the page tree but "index" pages, like blogs, are in the page tree and
// display some or all of the blogPosts according to their own criteria.
self.getIndexTypes = function(instanceTypeOrInstance) {
var instanceTypeName = instanceTypeOrInstance.type || instanceTypeOrInstance;
var instanceTypes = [];
var i;
return _.filter(self.types, function(type) {
return (type._instance === instanceTypeName);
});
};
// Get the names of the index types corresponding to an instance type or the name of an
// instance type
self.getIndexTypeNames = function(instanceTypeOrInstance) {
return _.pluck(self.getIndexTypes(instanceTypeOrInstance), 'name');
};
// Returns the manager object corresponding to a type name or an object of the type.
//
// Some manager objects are responsible for two types: an instance type
// ("blogPost") that does not appear in the page tree, and an index type
// ("blog") that does. In such cases the manager object has an _instance
// property which indicates the instance type name and a `name` property
// which indicates the index type name.
//
// If the manager object has an _instance property, then
// it will have both .get and .getIndexes methods for retrieving the
// instances and the indexes, respectively. If not, then the
// .get method should always be used.
self.getManager = function(nameOrObject) {
var name = nameOrObject.type || nameOrObject;
var instanceManager = self.getIndexTypes(name)[0];
if (instanceManager) {
return instanceManager;
}
var indexManager = _.find(self.types, function(type) {
return type.name === name;
});
return indexManager;
};
// Get all the instance type names: the type names which have a corresponding
// index type - snippet, event, blogPost, etc.
self.getAllInstanceTypeNames = function() {
var result = [];
_.each(self.types, function(type) {
if (type._instance) {
result.push(type._instance);
}
});
return result;
};
// May be called to re-order page types. Called automatically once at the end
// of initialization, which is usually sufficient now that the `types` option
// is permitted to contain types that will later be reinitialized by other
// modules, e.g. blog
self.setMenu = function(choices) {
apos.pushGlobalData({
aposPages: {
menu: choices
}
});
};
if (!options.types) {
options.types = [ { name: 'default', label: 'Default' } ];
}
self.types = [];
if (options.ui === undefined) {
options.ui = true;
}
// Broken out to a method for easy testing
self._newRoute = function(req, res) {
var data = req.body;
return self.insertPage(req, req.body, function(err, page) {
if (err) {
res.statusCode = 500;
return res.send('error');
}
return res.send(JSON.stringify(page));
});
};
// Insert a page. The req argument is examined for permissions purposes.
// The data argument is consulted for title, parent (slug of parent page),
// published, tags, type, seoDescription, typeSettings and otherTypeSettings,
// and is validated, so that you may safely pass it in directly from the browser
// (req.body), but this method may also be called to add pages for other reasons.
// If data.areas is passed then content areas may be inserted directly at
// insert time; this is useful during import operations. Areas are also
// sanitized. The new page is the last child of its parent.
self.insertPage = function(req, data, callback) {
var parent;
var page;
var parentSlug;
var title;
var seoDescription;
var type;
var nextRank;
var published;
var tags;
title = apos.sanitizeString(data.title).trim();
// Validation is annoying, automatic cleanup is awesome
if (!title.length) {
title = 'New Page';
}
seoDescription = apos.sanitizeString(data.seoDescription).trim();
published = apos.sanitizeBoolean(data.published, true);
tags = apos.sanitizeTags(data.tags);
type = determineType(data.type);
return async.series([ getParent, permissions, getNextRank, insertPage ], function(err) {
if (err) {
return callback(err);
}
return callback(null, page);
});
function getParent(callback) {
parentSlug = data.parent;
return apos.getPage(req, parentSlug, function(err, parentArg) {
parent = parentArg;
if ((!err) && (!parent)) {
err = 'Bad parent';
}
return callback(err);
});
}
function permissions(callback) {
return apos.permissions(req, 'edit-page', parent, function(err) {
// If there is no permissions error then note that we are cool
// enough to manage the page
return callback(err);
});
}
// TODO: there's a potential race condition here. It's not a huge deal,
// having two pages with the same rank just leads to them sorting
// randomly, the page tree is not destroyed. But we should have a
// cleanup task or a lock mechanism
function getNextRank(callback) {
return self.getNextRank(parent, function(err, rank) {
if (err) {
return callback(err);
}
nextRank = rank;
return callback(null);
});
}
function insertPage(callback) {
page = { title: title, seoDescription: seoDescription, published: published, tags: tags, type: type.name, level: parent.level + 1, areas: {}, path: parent.path + '/' + apos.slugify(title), slug: apos.addSlashIfNeeded(parentSlug) + apos.slugify(title), rank: nextRank };
// Permissions initially match those of the parent
page.viewGroupIds = parent.viewGroupIds;
page.viewPersonIds = parent.viewPersonIds;
page.editGroupIds = parent.editGroupIds;
page.editPersonIds = parent.editPersonIds;
if (parent.loginRequired) {
page.loginRequired = parent.loginRequired;
}
return async.series({
applyPermissions: function(callback) {
return self.applyPermissions(req, data, page, callback);
},
sanitizeTypeSettings: function(callback) {
return addSanitizedTypeData(req, data.typeSettings, page, type, callback);
},
// To be nice we keep the type settings around for other page types the user
// thought about giving this page. This avoids pain if the user switches and
// switches back. Alas it means we must keep validating them on save
sanitizeOtherTypeSettings: function(callback) {
return self.sanitizeOtherTypeSettings(req, data.otherTypeSettings, page, callback);
},
sanitizeAreas: function(callback) {
if (data.areas) {
page.areas = {};
_.each(data.areas, function(value, key) {
var items = self._apos.sanitizeItems(value.items || []);
page.areas[key] = { items: items };
});
}
return callback(null);
},
putPage: function(callback) {
return apos.putPage(req, page.slug, page, callback);
}
}, function(err) {
if (err) {
return callback(err);
}
return callback(null, page);
});
}
};
/**
* Get the correct rank to assign to a newly inserted subpage
* (one higher than the rank of any existing page). By default
* any pages with ranks greater than or equal to 1000000 are
* ignored, so that newly inserted pages do not come
* after system pages like the trashcan. However if options.system
* is true then the rank returned will be at least 1000000 and
* higher than any existing child, including system pages.
*
* "parent" must be a page object. "options" may
* be skipped in favor of just two arguments. "cb" is the callback.
* The callback receives an error if any, and if no error,
* the rank for the new page.
*/
self.getNextRank = function(parent, options, callback) {
if (!callback) {
callback = options;
options = {};
}
var criteria = {
path: new RegExp('^' + RegExp.quote(parent.path + '/'))
};
if (!options.system) {
criteria.rank = { $lt: 1000000 };
}
return apos.pages.find(criteria, { rank: 1 }).sort({ rank: -1 }).limit(1).toArray(function(err, pages) {
if (err) {
return callback(err);
}
if (!pages.length) {
return callback(null, options.system ? 1000000 : 1);
}
return callback(null, pages[0].rank + 1);
});
};
// Implementation of the /edit route which manipulates page settings. Broken out to
// a method for easier unit testing
self._editRoute = function(req, res) {
var page;
var originalSlug;
var originalPath;
var slug;
var title;
var published;
var tags;
var type;
var seoDescription;
title = apos.sanitizeString(req.body.title).trim();
seoDescription = apos.sanitizeString(req.body.seoDescription).trim();
// Validation is annoying, automatic cleanup is awesome
if (!title.length) {
title = 'Untitled Page';
}
published = apos.sanitizeBoolean(req.body.published, true);
tags = apos.sanitizeTags(req.body.tags);
// Allows simple edits of page settings that aren't interested in changing the slug.
// If you are allowing slug edits you must supply originalSlug.
originalSlug = req.body.originalSlug || req.body.slug;
slug = req.body.slug;
slug = apos.slugify(slug, { allow: '/' });
// Make sure they don't turn it into a virtual page
if (!slug.match(/^\//)) {
slug = '/' + slug;
}
// Eliminate double slashes
slug = slug.replace(/\/+/g, '/');
// Eliminate trailing slashes
slug = slug.replace(/\/$/, '');
// ... But never eliminate the leading /
if (!slug.length) {
slug = '/';
}
async.series([ getPage, permissions, updatePage, redirect, updateDescendants ], sendPage);
function getPage(callback) {
return apos.getPage(req, originalSlug, function(err, pageArg) {
page = pageArg;
if ((!err) && (!page)) {
err = 'Bad page';
}
originalPath = page.path;
return callback(err);
});
}
function permissions(callback) {
return apos.permissions(req, 'edit-page', page, function(err) {
// If there is no permissions error then we are cool
// enough to edit the page
return callback(err);
});
}
function updatePage(callback) {
page.title = title;
page.seoDescription = seoDescription;
page.published = published;
page.slug = slug;
page.tags = tags;
type = determineType(req.body.type, page.type);
page.type = type.name;
if ((slug !== originalSlug) && (originalSlug === '/')) {
return callback('Cannot change the slug of the home page');
}
return async.series({
applyPermissions: function(callback) {
return self.applyPermissions(req, req.body, page, callback);
},
sanitizeTypeSettings: function(callback) {
return addSanitizedTypeData(req, req.body.typeSettings, page, type, callback);
},
// To be nice we keep the type settings around for other page types this page
// has formerly had. This avoids pain if the user switches and switches back.
// Alas it means we must keep validating them on save
sanitizeOtherTypeSettings: function(callback) {
return self.sanitizeOtherTypeSettings(req, req.body.otherTypeSettings, page, callback);
},
putPage: function(callback) {
return apos.putPage(req, originalSlug, page, callback);
}
}, callback);
}
function redirect(callba