UNPKG

apostrophe-pages

Version:

Adds trees of pages to the Apostrophe content management system

1,388 lines (1,276 loc) 80.4 kB
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