apostrophe
Version:
Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.
366 lines (344 loc) • 12.6 kB
JavaScript
var _ = require('lodash');
var async = require('async');
var extend = require('extend');
var sanitizeHtml = require('sanitize-html');
var deep = require('deep-get-set');
/**
* areas
* @augments Augments the apos object with methods which store,
* retrieve and manipulate areas. An area is a series of zero or more content items,
* which may be rich text blocks or widgets. Areas are always stored within pages.
* @see pages
*/
module.exports = function(self) {
// getArea retrieves an area from MongoDB. All areas must be part
// of a page, thus the slug must look like: my-page-slug:areaname
//
// Invokes the callback with an error if any, and if no error,
// the area object requested if it exists. If the area does not
// exist, both parameters to the callback are null.
//
// A 'req' object is needed to provide a context for permissions.
// If the user does not have permission to view the page on which
// the area resides an error is reported. If the `editable` option
// is true then an error is reported unless the user has permission
// to edit the page on which the area resides.
//
// If it exists, the area object is guaranteed to have `slug` and
// `content` properties. The `content` property contains rich content
// markup ready to display in the browser.
//
// If 'slug' matches the following pattern:
//
// /cats/about:sidebar
//
// Then 'sidebar' is assumed to be the name of an area stored
// within the page object with the slug /cats/about. That
// object is fetched from the pages collection and the relevant area,
// if present, is delivered.
//
// This is an efficient way to store related areas
// that are usually desired at the same time, because the getPage method
// returns the entire page object, including all of its areas.
//
// You may skip the "options" parameter.
//
// By default, if an area contains items that have load functions, those
// load functions are invoked and the callback is not called until they
// complete. This means that items that require storage outside of
// the area collection, or data from APIs, can load that data at the time
// they are fetched. Set the 'load' option to false if you do not want this.
//
// The use of dot notation in the area slug is permitted. Dot notation
// is used to access areas nested in array schema fields.
self.getArea = function(req, slug, options, callback) {
if (typeof(options) === 'function') {
callback = options;
options = {};
}
if (options.load === undefined) {
options.load = true;
}
// Allow dot notation to access areas in array fields
var matches = slug.match(/^(.*?)\:((\w+)(\.\w+)*)$/);
if (!matches) {
console.error(slug);
return callback('All area slugs must now be page-based: page-slug:areaname');
}
// This area is part of a page
var pageSlug = matches[1];
var areaSlug = matches[2];
var area;
// Retrieve only the desired area
var projection = {};
projection[areaSlug] = 1;
self.get(req, { slug: pageSlug }, { editable: options.editable, fields: projection }, function (err, results) {
if (err) {
return callback(err);
}
var page = results.pages[0];
area = page && deep(page, areaSlug);
if (area) {
// What is stored in the db might be lagging behind the reality
// if the slug of the page has changed. Always return it in an
// up to date form
area.slug = pageSlug + ':' + areaSlug;
return loadersThenCallback(area);
}
// Nonexistence isn't an error, it's just nonexistence
return callback(err, null);
});
function loadersThenCallback(area) {
if (!area) {
// Careful, this is not an error, don't crash
return callback(null, null);
}
function after() {
return callback(null, area);
}
if (options.load) {
return self.callLoadersForArea(req, area, after);
} else {
return after();
}
}
};
var forbiddenAreaNames = {
pagePermissions: 1,
permissions: 1,
slug: 1,
path: 1,
tags: 1,
level: 1,
title: 1,
// Legacy, migration could turn them into pagePermissions
viewPersonIds: 1,
editPersonIds: 1,
viewGroupIds: 1,
editGroupIds: 1
};
// putArea stores an area in a page.
//
// Invokes the callback with an error if any, and if no error,
// the area object with its slug property set to the slug under
// which it was stored with putArea.
//
// The slug must match the following pattern:
//
// /cats/about:sidebar
//
// 'sidebar' is assumed to be the name of an area stored
// within the page object with the slug /cats/about.
// If the page object was previously empty it now looks like:
//
// {
// slug: '/cats/about',
// sidebar: {
// slug: '/cats/about/:sidebar',
// items: 'whatever your area.items property was'
// type: 'area'
// }
// }
// }
//
// If a page does not exist, the user has permission to create pages,
// and the slug does not start with /, this method will create it,
// as a page with no `type` property. If the page has a type property or
// resides in the page tree you should create it with putPage rather
// than using this method.
//
// This create-on-demand behavior is intended for
// simple virtual pages used to hold things like a
// global footer area.
//
// Dot notation is permitted in area slugs.
//
// The req argument is required for permissions checking.
//
// This method is implemeneted via getPage and putPage to ensure
// consistent behavior. A simple update() is tempting but would
// not implement versioning, workflow, etc. correctly.
//
// If workflow is in effect, the area is stored in
// the draft property of the page.
self.putArea = function(req, slug, area, callback) {
var pageOrSlug;
// Allow dot notation to access areas in array fields
var matches = slug.match(/^(.*?)\:((\w+)(\.\w+)*)$/);
if (!matches) {
console.error(slug);
return callback('Area slugs now must be page-based: page-slug:areaname');
}
var pageSlug = matches[1];
var areaSlug = matches[2];
if (forbiddenAreaNames[areaSlug]) {
return callback('forbidden area name, conflicts with core property: ' + areaSlug);
}
var page;
return async.series({
get: function(callback) {
// Get it without permissions so we're sure if it exists,
// then we'll do permissions checks as needed
return self.getPage(req, pageSlug, { workflow: true, permissions: false }, function(err, result) {
if (err) {
return callback(err);
}
page = result;
if (!page) {
// If it is a tree page it must be created first before
// any areas can be stored in it
if (pageSlug.substr(0, 1) === '/') {
return callback('notfound');
}
// OK to make virtual pages on the fly if we're allowed to
// create pages
return callback(self.permissions.can(req, 'edit-page') ? null : 'forbidden');
}
if (!self.permissions.can(req, 'edit-page', page)) {
return callback('forbidden');
}
var existing = deep(page, areaSlug);
if (existing && (existing['type'] !== 'area')) {
return callback('area name conflicts with non-area property: ' + areaSlug);
}
return callback(null);
}, callback);
},
put: function(callback) {
if (!page) {
page = {
slug: pageSlug
};
}
try {
deep(page, areaSlug, area);
} catch (e) {
return callback(new Error('dot notation used to store area under nonexistent parent key: ' + areaSlug));
}
return self.putPage(req, pageSlug, { workflow: [ areaSlug ] }, page, callback);
}
}, callback);
};
// Invoke loaders for any items in this area that have loaders, then
// invoke callback. Loaders are expected to report failure as appropriate
// to their needs by setting item properties that their templates can
// use to display that when relevant, so there is no formal error
// handling for loaders
// The req object is available so that loaders can consider permissions
// and perform appropriate caching for the lifetime of the request.
self.callLoadersForArea = function(req, area, callback) {
// Run loaders in series so that we can use semaphores
// in the req object safely. You'll get all the parallelism
// you could possibly want from simultaneous users.
async.mapSeries(area.items, function(item, callback) {
if (!self.itemTypes[item.type]) {
console.error('WARNING: unrecognized item type ' + item.type + ' encountered in area, URL was ' + req.url);
return setImmediate(callback);
}
if (self.itemTypes[item.type].load) {
req.traceIn('load-' + item.type);
return self.itemTypes[item.type].load(req, item, function(err) {
req.traceOut();
return callback(err);
});
} else {
return setImmediate(callback);
}
}, function(err) {
return callback(err);
});
};
// Convert an area to plaintext. This will only contain text
// for items that clearly have an appropriate plaintext
// representation for the public, so most widgets will not want
// to be represented as they have no reasonable plaintext
// equivalent, but you can define the 'getPlaintext' method
// for any widget to return one (see self.itemTypes for the
// richText example).
//
// Plaintext means truly plain, so if you want to output the
// text with nunjucks, be sure to use the "e" filter.
//
// If the truncate option is present, it is used as a character
// limit. The plaintext is cut at the closest word boundary
// before that length. If this cannot be done a hard cutoff is
// applied so that the result is never longer than
// options.truncate characters.
//
// You may call with the page, areaName, options syntax:
//
// {{ apos.getAreaPlaintext(page, 'body', { truncate: 200 }) }}
//
// Or a single options object:
//
// {{ apos.getAreaPlaintext({ area: page.body, truncate: 200 }) }}
self.getAreaPlaintext = function(page, name, options) {
if (arguments.length === 1) {
options = page;
} else {
options.area = page[name];
}
var area = options.area;
if (!area) {
return '';
}
var t = '';
_.each(area.items, function(item) {
// Do not crash if an unsupported item is present in an area
if (!self.itemTypes[item.type]) {
return;
}
if (self.itemTypes[item.type].getPlaintext) {
if (t.length) {
t += "\n";
}
t += self.itemTypes[item.type].getPlaintext(item);
}
});
if (options.truncate) {
t = self.truncatePlaintext(t, options.truncate);
}
return t;
};
// Very handy for imports of all kinds: convert plaintext to an area with
// one rich text item if it is not blank, otherwise an empty area. null and
// undefined are tolerated and converted to empty areas.
self.textToArea = function(text) {
var area = { type: 'area', items: [], type: 'area' };
if ((typeof(text) === 'string') && text.length) {
area.items.push({
type: 'richText',
content: self.escapeHtml(text, true)
});
}
return area;
};
// Convert HTML to an area with a single richText element. Passes it
// through sanitizeHtml.
self.htmlToArea = function(html) {
html = sanitizeHtml(html || '');
var area = {
type: 'area',
items: [
{
type: 'richText',
content: html
}
]
};
return area;
};
// Convert either plaintext or HTML to an area with a single richText element.
// Makes its best guess as to which one it's dealing with. Also tolerates
// null or undefined, resulting in an initially empty richText.
self.mixedToArea = function(s) {
s = s || '';
// If it smells like HTML treat it as such otherwise treat it as plaintext.
// They have both. It's crazypants. -Tom
if (s.match(/<[A-Za-z]/)) {
return self.htmlToArea(s);
} else {
return self.textToArea(s);
}
};
};