diff --git a/content/settings/README.md b/content/settings/README.md
index 480ae78be3..eaaad52042 100644
--- a/content/settings/README.md
+++ b/content/settings/README.md
@@ -11,7 +11,7 @@ routes:
collections:
/:
- route: '{globals.permalinks}'
+ permalink: '{globals.permalinks}'
template:
- home
- index
diff --git a/core/server/api/db.js b/core/server/api/db.js
index 566a7227ec..d02dce8deb 100644
--- a/core/server/api/db.js
+++ b/core/server/api/db.js
@@ -107,7 +107,7 @@ db = {
*/
deleteAllContent: function deleteAllContent(options) {
var tasks,
- queryOpts = {columns: 'id', context: {internal: true}};
+ queryOpts = {columns: 'id', context: {internal: true}, destroyAll: true};
options = options || {};
diff --git a/core/server/apps/amp/lib/router.js b/core/server/apps/amp/lib/router.js
index d8d68c76a5..f110ba1657 100644
--- a/core/server/apps/amp/lib/router.js
+++ b/core/server/apps/amp/lib/router.js
@@ -4,9 +4,8 @@ var path = require('path'),
// Dirty requires
common = require('../../../lib/common'),
- postLookup = require('../../../controllers/frontend/post-lookup'),
- renderer = require('../../../controllers/frontend/renderer'),
-
+ urlService = require('../../../services/url'),
+ helpers = require('../../../services/routing/helpers'),
templateName = 'amp';
function _renderer(req, res, next) {
@@ -28,7 +27,7 @@ function _renderer(req, res, next) {
}
// Render Call
- return renderer(req, res, data);
+ return helpers.renderer(req, res, data);
}
// This here is a controller.
@@ -36,7 +35,38 @@ function _renderer(req, res, next) {
function getPostData(req, res, next) {
req.body = req.body || {};
- postLookup(res.locals.relativeUrl)
+ const urlWithSubdirectoryWithoutAmp = req.originalUrl.match(/(.*?\/)amp/)[1];
+ const urlWithoutSubdirectoryWithoutAmp = res.locals.relativeUrl.match(/(.*?\/)amp/)[1];
+
+ /**
+ * @NOTE
+ *
+ * We have to figure out the target permalink, otherwise it would be possible to serve a post
+ * which lives in two collections.
+ *
+ * @TODO:
+ *
+ * This is not optimal and caused by the fact how apps currently work. But apps weren't designed
+ * for dynamic routing.
+ *
+ * I think if the responsible, target router would first take care fetching/determining the post, the
+ * request could then be forwarded to this app. Then we don't have to:
+ *
+ * 1. care about fetching the post
+ * 2. care about if the post can be served
+ * 3. then this app would act like an extension
+ *
+ * The challenge is to design different types of apps e.g. extensions of routers, standalone pages etc.
+ */
+ const permalinks = urlService.getPermalinkByUrl(urlWithSubdirectoryWithoutAmp, {withUrlOptions: true});
+
+ if (!permalinks) {
+ return next(new common.errors.NotFoundError({
+ message: common.i18n.t('errors.errors.pageNotFound')
+ }));
+ }
+
+ helpers.postLookup(urlWithoutSubdirectoryWithoutAmp, {permalinks: permalinks})
.then(function handleResult(result) {
if (result && result.post) {
req.body.post = result.post;
diff --git a/core/server/apps/private-blogging/lib/router.js b/core/server/apps/private-blogging/lib/router.js
index be1c2fbdac..465730fd6d 100644
--- a/core/server/apps/private-blogging/lib/router.js
+++ b/core/server/apps/private-blogging/lib/router.js
@@ -1,12 +1,10 @@
-var path = require('path'),
- express = require('express'),
- middleware = require('./middleware'),
- bodyParser = require('body-parser'),
- renderer = require('../../../controllers/frontend/renderer'),
- brute = require('../../../web/middleware/brute'),
-
+const path = require('path'),
+ express = require('express'),
+ middleware = require('./middleware'),
+ bodyParser = require('body-parser'),
+ routing = require('../../../services/routing'),
+ brute = require('../../../web/middleware/brute'),
templateName = 'private',
-
privateRouter = express.Router();
function _renderer(req, res) {
@@ -27,7 +25,7 @@ function _renderer(req, res) {
}
// Render Call
- return renderer(req, res, data);
+ return routing.helpers.renderer(req, res, data);
}
// password-protected frontend route
diff --git a/core/server/apps/subscribers/lib/helpers/subscribe_form.js b/core/server/apps/subscribers/lib/helpers/subscribe_form.js
index 2c78bc5c6d..d6c3352984 100644
--- a/core/server/apps/subscribers/lib/helpers/subscribe_form.js
+++ b/core/server/apps/subscribers/lib/helpers/subscribe_form.js
@@ -5,7 +5,7 @@ var _ = require('lodash'),
// (Less) dirty requires
proxy = require('../../../../helpers/proxy'),
templates = proxy.templates,
- url = proxy.url,
+ urlService = proxy.urlService,
SafeString = proxy.SafeString,
params = ['error', 'success', 'email'],
@@ -39,7 +39,7 @@ module.exports = function subscribe_form(options) { // eslint-disable-line camel
var root = options.data.root,
data = _.merge({}, options.hash, _.pick(root, params), {
// routeKeywords.subscribe: 'subscribe'
- action: url.urlJoin('/', url.getSubdir(), 'subscribe/'),
+ action: urlService.utils.urlJoin('/', urlService.utils.getSubdir(), 'subscribe/'),
script: new SafeString(subscribeScript),
hidden: new SafeString(
makeHidden('confirm') +
diff --git a/core/server/apps/subscribers/lib/router.js b/core/server/apps/subscribers/lib/router.js
index 209f849c0a..620c95b67f 100644
--- a/core/server/apps/subscribers/lib/router.js
+++ b/core/server/apps/subscribers/lib/router.js
@@ -7,9 +7,9 @@ var path = require('path'),
// Dirty requires
api = require('../../../api'),
common = require('../../../lib/common'),
+ urlService = require('../../../services/url'),
validator = require('../../../data/validation').validator,
- postLookup = require('../../../controllers/frontend/post-lookup'),
- renderer = require('../../../controllers/frontend/renderer'),
+ routing = require('../../../services/routing'),
templateName = 'subscribe';
@@ -27,7 +27,7 @@ function _renderer(req, res) {
var data = req.body;
// Render Call
- return renderer(req, res, data);
+ return routing.helpers.renderer(req, res, data);
}
/**
@@ -63,24 +63,17 @@ function santizeUrl(url) {
function handleSource(req, res, next) {
req.body.subscribed_url = santizeUrl(req.body.location);
req.body.subscribed_referrer = santizeUrl(req.body.referrer);
+
delete req.body.location;
delete req.body.referrer;
- postLookup(req.body.subscribed_url)
- .then(function (result) {
- if (result && result.post) {
- req.body.post_id = result.post.id;
- }
+ const resource = urlService.getResource(urlService.utils.absoluteToRelative(req.body.subscribed_url));
- next();
- })
- .catch(function (err) {
- if (err instanceof common.errors.NotFoundError) {
- return next();
- }
+ if (resource) {
+ req.body.post_id = resource.data.id;
+ }
- next(err);
- });
+ next();
}
function storeSubscriber(req, res, next) {
diff --git a/core/server/controllers/channel.js b/core/server/controllers/channel.js
deleted file mode 100644
index 7d5a28ec99..0000000000
--- a/core/server/controllers/channel.js
+++ /dev/null
@@ -1,46 +0,0 @@
-var _ = require('lodash'),
- common = require('../lib/common'),
- security = require('../lib/security'),
- filters = require('../filters'),
- handleError = require('./frontend/error'),
- fetchData = require('./frontend/fetch-data'),
- setRequestIsSecure = require('./frontend/secure'),
- renderChannel = require('./frontend/render-channel');
-
-// This here is a controller.
-// The "route" is handled in services/channels/router.js
-// There's both a top-level channelS router, and an individual channel one
-module.exports = function channelController(req, res, next) {
- // Parse the parameters we need from the URL
- var pageParam = req.params.page !== undefined ? req.params.page : 1,
- slugParam = req.params.slug ? security.string.safe(req.params.slug) : undefined;
-
- // @TODO: fix this, we shouldn't change the channel object!
- // Set page on postOptions for the query made later
- res.locals.channel.postOptions.page = pageParam;
- res.locals.channel.slugParam = slugParam;
-
- // Call fetchData to get everything we need from the API
- return fetchData(res.locals.channel).then(function handleResult(result) {
- // If page is greater than number of pages we have, go straight to 404
- if (pageParam > result.meta.pagination.pages) {
- return next(new common.errors.NotFoundError({message: common.i18n.t('errors.errors.pageNotFound')}));
- }
-
- // Format data 1
- // @TODO: figure out if this can be removed, it's supposed to ensure that absolutely URLs get generated
- // correctly for the various objects, but I believe it doesn't work and a different approach is needed.
- setRequestIsSecure(req, result.posts);
- _.each(result.data, function (data) {
- setRequestIsSecure(req, data);
- });
-
- // @TODO: properly design these filters
- filters.doFilter('prePostsRender', result.posts, res.locals)
- .then(function (posts) {
- result.posts = posts;
- return result;
- })
- .then(renderChannel(req, res));
- }).catch(handleError(next));
-};
diff --git a/core/server/controllers/entry.js b/core/server/controllers/entry.js
index 352ea21b33..40f6ad6d8d 100644
--- a/core/server/controllers/entry.js
+++ b/core/server/controllers/entry.js
@@ -34,8 +34,20 @@ module.exports = function entryController(req, res, next) {
return urlService.utils.redirectToAdmin(302, res, '/editor/' + post.id);
}
- // CASE: permalink is not valid anymore, we redirect him permanently to the correct one
- if (post.url !== req.path) {
+ //
+ /**
+ * CASE: Permalink is not valid anymore, we redirect him permanently to the correct one
+ * This usually only happens if you switch to date permalinks or if you have date permalinks
+ * enabled and the published date changes.
+ *
+ * @NOTE
+ *
+ * The resource url (post.url) always contains the subdirectory. This was different before dynamic routing.
+ * Because with dynamic routing we have a service which knows where a resource lives - and this includes the
+ * subdirectory. Otherwise every time we use a resource url, we would need to take care of the subdirectory.
+ * That's why we have to use the original url, which contains the sub-directory.
+ */
+ if (post.url !== req.originalUrl) {
return urlService.utils.redirect301(res, post.url);
}
diff --git a/core/server/controllers/frontend/fetch-data.js b/core/server/controllers/frontend/fetch-data.js
deleted file mode 100644
index 9dffc254f0..0000000000
--- a/core/server/controllers/frontend/fetch-data.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * # Fetch Data
- * Dynamically build and execute queries on the API for channels
- */
-var api = require('../../api'),
- _ = require('lodash'),
- Promise = require('bluebird'),
- themes = require('../../services/themes'),
- queryDefaults,
- defaultPostQuery = {};
-
-// The default settings for a default post query
-queryDefaults = {
- type: 'browse',
- resource: 'posts',
- options: {}
-};
-
-/**
- * Default post query needs to always include author, authors & tags
- *
- * @deprecated: `author`, will be removed in Ghost 2.0
- */
-_.extend(defaultPostQuery, queryDefaults, {
- options: {
- include: 'author,authors,tags'
- }
-});
-
-/**
- * ## Fetch Posts Per page
- * Grab the postsPerPage setting
- *
- * @param {Object} options
- * @returns {Object} postOptions
- */
-function fetchPostsPerPage(options) {
- options = options || {};
-
- var postsPerPage = parseInt(themes.getActive().config('posts_per_page'));
-
- // No negative posts per page, must be number
- if (!isNaN(postsPerPage) && postsPerPage > 0) {
- options.limit = postsPerPage;
- }
-
- // Ensure the options key is present, so this can be merged with other options
- return {options: options};
-}
-
-/**
- * @typedef query
- * @
- */
-
-/**
- * ## Process Query
- * Takes a 'query' object, ensures that type, resource and options are set
- * Replaces occurrences of `%s` in options with slugParam
- * Converts the query config to a promise for the result
- *
- * @param {{type: String, resource: String, options: Object}} query
- * @param {String} slugParam
- * @returns {Promise} promise for an API call
- */
-function processQuery(query, slugParam) {
- query = _.cloneDeep(query);
-
- // Ensure that all the properties are filled out
- _.defaultsDeep(query, queryDefaults);
-
- // Replace any slugs
- _.each(query.options, function (option, name) {
- query.options[name] = _.isString(option) ? option.replace(/%s/g, slugParam) : option;
- });
-
- // Return a promise for the api query
- return api[query.resource][query.type](query.options);
-}
-
-/**
- * ## Fetch Data
- * Calls out to get posts per page, builds the final posts query & builds any additional queries
- * Wraps the queries using Promise.props to ensure it gets named responses
- * Does a first round of formatting on the response, and returns
- *
- * @param {Object} channelOptions
- * @returns {Promise} response
- */
-function fetchData(channelOptions) {
- // @TODO improve this further
- var pageOptions = channelOptions.isRSS ? {options: channelOptions.postOptions} : fetchPostsPerPage(channelOptions.postOptions),
- postQuery,
- props = {};
-
- // All channels must have a posts query, use the default if not provided
- postQuery = _.defaultsDeep({}, pageOptions, defaultPostQuery);
- props.posts = processQuery(postQuery, channelOptions.slugParam);
-
- _.each(channelOptions.data, function (query, name) {
- props[name] = processQuery(query, channelOptions.slugParam);
- });
-
- return Promise.props(props).then(function formatResponse(results) {
- var response = _.cloneDeep(results.posts);
- delete results.posts;
-
- // process any remaining data
- if (!_.isEmpty(results)) {
- response.data = {};
-
- _.each(results, function (result, name) {
- if (channelOptions.data[name].type === 'browse') {
- response.data[name] = result;
- } else {
- response.data[name] = result[channelOptions.data[name].resource];
- }
- });
- }
-
- return response;
- });
-}
-
-module.exports = fetchData;
diff --git a/core/server/controllers/frontend/post-lookup.js b/core/server/controllers/frontend/post-lookup.js
deleted file mode 100644
index 7292feb71d..0000000000
--- a/core/server/controllers/frontend/post-lookup.js
+++ /dev/null
@@ -1,73 +0,0 @@
-var _ = require('lodash'),
- Promise = require('bluebird'),
- url = require('url'),
- routeMatch = require('path-match')(),
- api = require('../../api'),
- settingsCache = require('../../services/settings/cache'),
- optionsFormat = '/:options?';
-
-function getOptionsFormat(linkStructure) {
- return linkStructure.replace(/\/$/, '') + optionsFormat;
-}
-
-function postLookup(postUrl) {
- var postPath = url.parse(postUrl).path,
- postPermalink = settingsCache.get('permalinks'),
- pagePermalink = '/:slug/',
- isEditURL = false,
- matchFuncPost,
- matchFuncPage,
- postParams,
- params;
-
- // Convert saved permalink into a path-match function
- matchFuncPost = routeMatch(getOptionsFormat(postPermalink));
- matchFuncPage = routeMatch(getOptionsFormat(pagePermalink));
-
- postParams = matchFuncPost(postPath);
-
- // Check if the path matches the permalink structure.
- // If there are no matches found, test to see if this is a page instead
- params = postParams || matchFuncPage(postPath);
-
- // if there are no matches for either then return empty
- if (params === false) {
- return Promise.resolve();
- }
-
- // If params contains options, and it is equal to 'edit', this is an edit URL
- if (params.options && params.options.toLowerCase() === 'edit') {
- isEditURL = true;
- }
-
- /**
- * Query database to find post.
- *
- * @deprecated: `author`, will be removed in Ghost 2.0
- */
- return api.posts.read(_.extend(_.pick(params, 'slug', 'id'), {include: 'author,authors,tags'})).then(function then(result) {
- var post = result.posts[0];
-
- if (!post) {
- return Promise.resolve();
- }
-
- // CASE: we originally couldn't match the post based on date permalink and we tried to check if its a page
- if (!post.page && !postParams) {
- return Promise.resolve();
- }
-
- // CASE: we only support /:slug format for pages
- if (post.page && matchFuncPage(postPath) === false) {
- return Promise.resolve();
- }
-
- return {
- post: post,
- isEditURL: isEditURL,
- isUnknownOption: isEditURL ? false : params.options ? true : false
- };
- });
-}
-
-module.exports = postLookup;
diff --git a/core/server/controllers/frontend/render-channel.js b/core/server/controllers/frontend/render-channel.js
deleted file mode 100644
index f367d94031..0000000000
--- a/core/server/controllers/frontend/render-channel.js
+++ /dev/null
@@ -1,16 +0,0 @@
-var debug = require('ghost-ignition').debug('channels:render'),
- formatResponse = require('./format-response'),
- renderer = require('./renderer');
-
-module.exports = function renderChannel(req, res) {
- debug('renderChannel called');
- return function renderChannel(result) {
- // Renderer begin
- // Format data 2
- // Do final data formatting and then render
- var data = formatResponse.channel(result);
-
- // Render Call
- return renderer(req, res, data);
- };
-};
diff --git a/core/server/controllers/index.js b/core/server/controllers/index.js
deleted file mode 100644
index 2487ee4622..0000000000
--- a/core/server/controllers/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-module.exports = {
- preview: require('./preview'),
- entry: require('./entry')
-};
diff --git a/core/server/controllers/preview.js b/core/server/controllers/preview.js
index 534332f770..db5c2cd9d3 100644
--- a/core/server/controllers/preview.js
+++ b/core/server/controllers/preview.js
@@ -37,7 +37,7 @@ module.exports = function previewController(req, res, next) {
}
if (post.status === 'published') {
- return urlService.utils.redirect301(res, urlService.utils.urlFor('post', {post: post}));
+ return urlService.utils.redirect301(res, urlService.getUrlByResourceId(post.id));
}
setRequestIsSecure(req, post);
diff --git a/core/server/controllers/rss.js b/core/server/controllers/rss.js
deleted file mode 100644
index 3f0c015cef..0000000000
--- a/core/server/controllers/rss.js
+++ /dev/null
@@ -1,70 +0,0 @@
-var _ = require('lodash'),
- url = require('url'),
- common = require('../lib/common'),
- security = require('../lib/security'),
- settingsCache = require('../services/settings/cache'),
-
- // Slightly less ugly temporary hack for location of things
- fetchData = require('./frontend/fetch-data'),
- handleError = require('./frontend/error'),
-
- rssService = require('../services/rss'),
- generate;
-
-// @TODO: is this the right logic? Where should this live?!
-function getBaseUrlForRSSReq(originalUrl, pageParam) {
- return url.parse(originalUrl).pathname.replace(new RegExp('/' + pageParam + '/$'), '/');
-}
-
-// @TODO: is this really correct? Should we be using meta data title?
-function getTitle(relatedData) {
- relatedData = relatedData || {};
- var titleStart = _.get(relatedData, 'author[0].name') || _.get(relatedData, 'tag[0].name') || '';
-
- titleStart += titleStart ? ' - ' : '';
- return titleStart + settingsCache.get('title');
-}
-
-// @TODO: merge this with the rest of the data processing for RSS
-// @TODO: swap the fetchData call + duplicate code from channels with something DRY
-function getData(channelOpts) {
- channelOpts.data = channelOpts.data || {};
-
- return fetchData(channelOpts).then(function formatResult(result) {
- var response = _.pick(result, ['posts', 'meta']);
-
- response.title = getTitle(result.data);
- response.description = settingsCache.get('description');
-
- return response;
- });
-}
-
-// This here is a controller.
-// The "route" is handled in services/channels/router.js
-// We can only generate RSS for channels, so that sorta makes sense, but the location is rubbish
-// @TODO finish refactoring this - it's now a controller
-generate = function generate(req, res, next) {
- // Parse the parameters we need from the URL
- var pageParam = req.params.page !== undefined ? req.params.page : 1,
- slugParam = req.params.slug ? security.string.safe(req.params.slug) : undefined,
- // Base URL needs to be the URL for the feed without pagination:
- baseUrl = getBaseUrlForRSSReq(req.originalUrl, pageParam);
-
- // @TODO: fix this, we shouldn't change the channel object!
- // Set page on postOptions for the query made later
- res.locals.channel.postOptions.page = pageParam;
- res.locals.channel.slugParam = slugParam;
-
- return getData(res.locals.channel).then(function handleResult(data) {
- // If page is greater than number of pages we have, go straight to 404
- if (pageParam > data.meta.pagination.pages) {
- return next(new common.errors.NotFoundError({message: common.i18n.t('errors.errors.pageNotFound')}));
- }
-
- // Render call - to a special RSS renderer
- return rssService.render(res, baseUrl, data);
- }).catch(handleError(next));
-};
-
-module.exports = generate;
diff --git a/core/server/data/meta/author_url.js b/core/server/data/meta/author_url.js
index a7aa3a91c3..fc39d8702d 100644
--- a/core/server/data/meta/author_url.js
+++ b/core/server/data/meta/author_url.js
@@ -6,12 +6,13 @@ function getAuthorUrl(data, absolute) {
context = context === 'amp' ? 'post' : context;
if (data.author) {
- return urlService.utils.urlFor('author', {author: data.author}, absolute);
+ return urlService.getUrlByResourceId(data.author.id, {absolute: absolute, secure: data.author.secure});
}
if (data[context] && data[context].primary_author) {
- return urlService.utils.urlFor('author', {author: data[context].primary_author}, absolute);
+ return urlService.getUrlByResourceId(data[context].primary_author.id, {absolute: absolute, secure: data[context].secure});
}
+
return null;
}
diff --git a/core/server/data/meta/rss_url.js b/core/server/data/meta/rss_url.js
index 5d0e5d2427..fe4c7c3fd0 100644
--- a/core/server/data/meta/rss_url.js
+++ b/core/server/data/meta/rss_url.js
@@ -1,7 +1,21 @@
-var urlService = require('../../services/url');
+const routingService = require('../../services/routing');
+/**
+ * https://github.com/TryGhost/Team/issues/65#issuecomment-393622816
+ *
+ * For now we output only the default rss feed link. And this is the first collection.
+ * If the first collection has rss disabled, we output nothing.
+ *
+ * @TODO: We are currently investigating this.
+ */
function getRssUrl(data, absolute) {
- return urlService.utils.urlFor('rss', {secure: data.secure}, absolute);
+ return routingService
+ .registry
+ .getFirstCollectionRouter()
+ .getRssUrl({
+ secure: data.secure,
+ absolute: absolute
+ });
}
module.exports = getRssUrl;
diff --git a/core/server/data/meta/url.js b/core/server/data/meta/url.js
index 7cc65c365c..c4c5c7a8e4 100644
--- a/core/server/data/meta/url.js
+++ b/core/server/data/meta/url.js
@@ -12,16 +12,8 @@ function sanitizeAmpUrl(url) {
}
function getUrl(data, absolute) {
- if (schema.isPost(data)) {
- return urlService.utils.urlFor('post', {post: data, secure: data.secure}, absolute);
- }
-
- if (schema.isTag(data)) {
- return urlService.utils.urlFor('tag', {tag: data, secure: data.secure}, absolute);
- }
-
- if (schema.isUser(data)) {
- return urlService.utils.urlFor('author', {author: data, secure: data.secure}, absolute);
+ if (schema.isPost(data) || schema.isTag(data) || schema.isUser(data)) {
+ return urlService.getUrlByResourceId(data.id, {secure: data.secure, absolute: absolute});
}
if (schema.isNav(data)) {
diff --git a/core/server/data/xml/sitemap/base-generator.js b/core/server/data/xml/sitemap/base-generator.js
index cb56d174e4..6a7861a48b 100644
--- a/core/server/data/xml/sitemap/base-generator.js
+++ b/core/server/data/xml/sitemap/base-generator.js
@@ -1,77 +1,28 @@
-var _ = require('lodash'),
+const _ = require('lodash'),
xml = require('xml'),
moment = require('moment'),
- Promise = require('bluebird'),
path = require('path'),
urlService = require('../../../services/url'),
- common = require('../../../lib/common'),
localUtils = require('./utils'),
- CHANGE_FREQ = 'weekly',
- XMLNS_DECLS;
+ CHANGE_FREQ = 'weekly';
// Sitemap specific xml namespace declarations that should not change
-XMLNS_DECLS = {
+const XMLNS_DECLS = {
_attr: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9',
'xmlns:image': 'http://www.google.com/schemas/sitemap-image/1.1'
}
};
-function BaseSiteMapGenerator() {
- this.lastModified = 0;
- this.nodeLookup = {};
- this.nodeTimeLookup = {};
- this.siteMapContent = '';
- this.dataEvents = common.events;
-}
+class BaseSiteMapGenerator {
+ constructor() {
+ this.nodeLookup = {};
+ this.nodeTimeLookup = {};
+ this.siteMapContent = null;
+ this.lastModified = 0;
+ }
-_.extend(BaseSiteMapGenerator.prototype, {
- init: function () {
- var self = this;
- return this.refreshAll().then(function () {
- return self.bindEvents();
- });
- },
-
- bindEvents: _.noop,
-
- getData: function () {
- return Promise.resolve([]);
- },
-
- refreshAll: function () {
- var self = this;
-
- // Load all data
- return this.getData().then(function (data) {
- // Generate SiteMap from data
- return self.generateXmlFromData(data);
- }).then(function (generatedXml) {
- self.siteMapContent = generatedXml;
- });
- },
-
- generateXmlFromData: function (data) {
- // Create all the url elements in JSON
- var self = this,
- nodes;
-
- nodes = _.reduce(data, function (nodeArr, datum) {
- var node = self.createUrlNodeFromDatum(datum);
-
- if (node) {
- self.updateLastModified(datum);
- self.updateLookups(datum, node);
- nodeArr.push(node);
- }
-
- return nodeArr;
- }, []);
-
- return this.generateXmlFromNodes(nodes);
- },
-
- generateXmlFromNodes: function () {
+ generateXmlFromNodes() {
var self = this,
// Get a mapping of node to timestamp
timedNodes = _.map(this.nodeLookup, function (node, id) {
@@ -93,70 +44,46 @@ _.extend(BaseSiteMapGenerator.prototype, {
// Return the xml
return localUtils.getDeclarations() + xml(data);
- },
+ }
- updateXmlFromNodes: function (urlElements) {
- var content = this.generateXmlFromNodes(urlElements);
-
- this.setSiteMapContent(content);
-
- return content;
- },
-
- addOrUpdateUrl: function (model) {
- var datum = model.toJSON(),
- node = this.createUrlNodeFromDatum(datum);
+ addUrl(url, datum) {
+ const node = this.createUrlNodeFromDatum(url, datum);
if (node) {
this.updateLastModified(datum);
- // TODO: Check if the node values changed, and if not don't regenerate
this.updateLookups(datum, node);
- this.updateXmlFromNodes();
+ // force regeneration of xml
+ this.siteMapContent = null;
}
- },
+ }
- removeUrl: function (model) {
- var datum = model.toJSON();
- // When the model is destroyed we need to fetch previousAttributes
- if (!datum.id) {
- datum = model.previousAttributes();
- }
+ removeUrl(url, datum) {
this.removeFromLookups(datum);
+ // force regeneration of xml
+ this.siteMapContent = null;
this.lastModified = Date.now();
+ }
- this.updateXmlFromNodes();
- },
-
- validateDatum: function () {
- return true;
- },
-
- getUrlForDatum: function () {
- return urlService.utils.urlFor('home', true);
- },
-
- getUrlForImage: function (image) {
- return urlService.utils.urlFor('image', {image: image}, true);
- },
-
- getPriorityForDatum: function () {
+ getPriorityForDatum() {
return 1.0;
- },
+ }
- getLastModifiedForDatum: function (datum) {
+ getLastModifiedForDatum(datum) {
return datum.updated_at || datum.published_at || datum.created_at;
- },
+ }
- createUrlNodeFromDatum: function (datum) {
- if (!this.validateDatum(datum)) {
- return false;
+ updateLastModified(datum) {
+ const lastModified = this.getLastModifiedForDatum(datum);
+
+ if (lastModified > this.lastModified) {
+ this.lastModified = lastModified;
}
+ }
- var url = this.getUrlForDatum(datum),
- priority = this.getPriorityForDatum(datum),
- node,
- imgNode;
+ createUrlNodeFromDatum(url, datum) {
+ const priority = this.getPriorityForDatum(datum);
+ let node, imgNode;
node = {
url: [
@@ -174,9 +101,9 @@ _.extend(BaseSiteMapGenerator.prototype, {
}
return node;
- },
+ }
- createImageNodeFromDatum: function (datum) {
+ createImageNodeFromDatum(datum) {
// Check for cover first because user has cover but the rest only have image
var image = datum.cover_image || datum.profile_image || datum.feature_image,
imageUrl,
@@ -187,7 +114,7 @@ _.extend(BaseSiteMapGenerator.prototype, {
}
// Grab the image url
- imageUrl = this.getUrlForImage(image);
+ imageUrl = urlService.utils.urlFor('image', {image: image}, true);
// Verify the url structure
if (!this.validateImageUrl(imageUrl)) {
@@ -204,36 +131,37 @@ _.extend(BaseSiteMapGenerator.prototype, {
return {
'image:image': imageEl
};
- },
+ }
- validateImageUrl: function (imageUrl) {
+ validateImageUrl(imageUrl) {
return !!imageUrl;
- },
+ }
- setSiteMapContent: function (content) {
- this.siteMapContent = content;
- },
-
- updateLastModified: function (datum) {
- var lastModified = this.getLastModifiedForDatum(datum);
-
- if (lastModified > this.lastModified) {
- this.lastModified = lastModified;
+ getXml() {
+ if (this.siteMapContent) {
+ return this.siteMapContent;
}
- },
- updateLookups: function (datum, node) {
+ const content = this.generateXmlFromNodes();
+ this.siteMapContent = content;
+ return content;
+ }
+
+ /**
+ * @NOTE
+ * The url service currently has no url update event.
+ * It removes and adds the url. If the url service extends it's
+ * feature set, we can detect if a node has changed.
+ */
+ updateLookups(datum, node) {
this.nodeLookup[datum.id] = node;
this.nodeTimeLookup[datum.id] = this.getLastModifiedForDatum(datum);
- },
-
- removeFromLookups: function (datum) {
- var lookup = this.nodeLookup;
- delete lookup[datum.id];
-
- lookup = this.nodeTimeLookup;
- delete lookup[datum.id];
}
-});
+
+ removeFromLookups(datum) {
+ delete this.nodeLookup[datum.id];
+ delete this.nodeTimeLookup[datum.id];
+ }
+}
module.exports = BaseSiteMapGenerator;
diff --git a/core/server/data/xml/sitemap/handler.js b/core/server/data/xml/sitemap/handler.js
index ce7073ec0b..4f68430502 100644
--- a/core/server/data/xml/sitemap/handler.js
+++ b/core/server/data/xml/sitemap/handler.js
@@ -1,66 +1,35 @@
-var _ = require('lodash'),
- config = require('../../../config'),
- sitemap = require('./index');
+const config = require('../../../config'),
+ Manager = require('./manager'),
+ manager = new Manager();
// Responsible for handling requests for sitemap files
module.exports = function handler(siteApp) {
- var resourceTypes = ['posts', 'authors', 'tags', 'pages'],
- verifyResourceType = function verifyResourceType(req, res, next) {
- if (!_.includes(resourceTypes, req.params.resource)) {
- return res.sendStatus(404);
- }
+ const verifyResourceType = function verifyResourceType(req, res, next) {
+ if (!manager.hasOwnProperty(req.params.resource)) {
+ return res.sendStatus(404);
+ }
- next();
- },
- getResourceSiteMapXml = function getResourceSiteMapXml(type, page) {
- return sitemap.getSiteMapXml(type, page);
- };
-
- siteApp.get('/sitemap.xml', function sitemapXML(req, res, next) {
- var siteMapXml = sitemap.getIndexXml();
+ next();
+ };
+ siteApp.get('/sitemap.xml', function sitemapXML(req, res) {
res.set({
'Cache-Control': 'public, max-age=' + config.get('caching:sitemap:maxAge'),
'Content-Type': 'text/xml'
});
- // CASE: returns null if sitemap is not initialized as below
- if (!siteMapXml) {
- sitemap.init()
- .then(function () {
- siteMapXml = sitemap.getIndexXml();
- res.send(siteMapXml);
- })
- .catch(function (err) {
- next(err);
- });
- } else {
- res.send(siteMapXml);
- }
+ res.send(manager.getIndexXml());
});
- siteApp.get('/sitemap-:resource.xml', verifyResourceType, function sitemapResourceXML(req, res, next) {
+ siteApp.get('/sitemap-:resource.xml', verifyResourceType, function sitemapResourceXML(req, res) {
var type = req.params.resource,
- page = 1,
- siteMapXml = getResourceSiteMapXml(type, page);
+ page = 1;
res.set({
'Cache-Control': 'public, max-age=' + config.get('caching:sitemap:maxAge'),
'Content-Type': 'text/xml'
});
- // CASE: returns null if sitemap is not initialized
- if (!siteMapXml) {
- sitemap.init()
- .then(function () {
- siteMapXml = getResourceSiteMapXml(type, page);
- res.send(siteMapXml);
- })
- .catch(function (err) {
- next(err);
- });
- } else {
- res.send(siteMapXml);
- }
+ res.send(manager.getSiteMapXml(type, page));
});
};
diff --git a/core/server/data/xml/sitemap/index-generator.js b/core/server/data/xml/sitemap/index-generator.js
index 120bb46c36..16d50db178 100644
--- a/core/server/data/xml/sitemap/index-generator.js
+++ b/core/server/data/xml/sitemap/index-generator.js
@@ -1,27 +1,23 @@
-var _ = require('lodash'),
- xml = require('xml'),
- moment = require('moment'),
+const _ = require('lodash'),
+ xml = require('xml'),
+ moment = require('moment'),
urlService = require('../../../services/url'),
- localUtils = require('./utils'),
- RESOURCES,
- XMLNS_DECLS;
+ localUtils = require('./utils');
-RESOURCES = ['pages', 'posts', 'authors', 'tags'];
-
-XMLNS_DECLS = {
+const XMLNS_DECLS = {
_attr: {
xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9'
}
};
-function SiteMapIndexGenerator(opts) {
- // Grab the other site map generators from the options
- _.extend(this, _.pick(opts, RESOURCES));
-}
+class SiteMapIndexGenerator {
+ constructor(options) {
+ options = options || {};
+ this.types = options.types;
+ }
-_.extend(SiteMapIndexGenerator.prototype, {
- getIndexXml: function () {
- var urlElements = this.generateSiteMapUrlElements(),
+ getXml() {
+ const urlElements = this.generateSiteMapUrlElements(),
data = {
// Concat the elements to the _attr declaration
sitemapindex: [XMLNS_DECLS].concat(urlElements)
@@ -29,16 +25,12 @@ _.extend(SiteMapIndexGenerator.prototype, {
// Return the xml
return localUtils.getDeclarations() + xml(data);
- },
+ }
- generateSiteMapUrlElements: function () {
- var self = this;
-
- return _.map(RESOURCES, function (resourceType) {
- var url = urlService.utils.urlFor({
- relativeUrl: '/sitemap-' + resourceType + '.xml'
- }, true),
- lastModified = self[resourceType].lastModified;
+ generateSiteMapUrlElements() {
+ return _.map(this.types, (resourceType) => {
+ var url = urlService.utils.urlFor({relativeUrl: '/sitemap-' + resourceType.name + '.xml'}, true),
+ lastModified = resourceType.lastModified;
return {
sitemap: [
@@ -48,6 +40,6 @@ _.extend(SiteMapIndexGenerator.prototype, {
};
});
}
-});
+}
module.exports = SiteMapIndexGenerator;
diff --git a/core/server/data/xml/sitemap/index.js b/core/server/data/xml/sitemap/index.js
deleted file mode 100644
index be8e8651dd..0000000000
--- a/core/server/data/xml/sitemap/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-var SiteMapManager = require('./manager');
-
-module.exports = new SiteMapManager();
diff --git a/core/server/data/xml/sitemap/manager.js b/core/server/data/xml/sitemap/manager.js
index 852a6bc078..5bbd5a8bb8 100644
--- a/core/server/data/xml/sitemap/manager.js
+++ b/core/server/data/xml/sitemap/manager.js
@@ -1,75 +1,73 @@
-var _ = require('lodash'),
- Promise = require('bluebird'),
+const common = require('../../../lib/common'),
IndexMapGenerator = require('./index-generator'),
PagesMapGenerator = require('./page-generator'),
PostsMapGenerator = require('./post-generator'),
UsersMapGenerator = require('./user-generator'),
- TagsMapGenerator = require('./tag-generator'),
- SiteMapManager;
+ TagsMapGenerator = require('./tag-generator');
-SiteMapManager = function (opts) {
- opts = opts || {};
+class SiteMapManager {
+ constructor(options) {
+ options = options || {};
- this.initialized = false;
+ this.pages = options.pages || this.createPagesGenerator(options);
+ this.posts = options.posts || this.createPostsGenerator(options);
+ this.users = this.authors = options.authors || this.createUsersGenerator(options);
+ this.tags = options.tags || this.createTagsGenerator(options);
+ this.index = options.index || this.createIndexGenerator(options);
- this.pages = opts.pages || this.createPagesGenerator(opts);
- this.posts = opts.posts || this.createPostsGenerator(opts);
- this.authors = opts.authors || this.createUsersGenerator(opts);
- this.tags = opts.tags || this.createTagsGenerator(opts);
+ common.events.on('router.created', (router) => {
+ if (router.name === 'StaticRoutesRouter') {
+ this.pages.addUrl(router.getRoute({absolute: true}), {id: router.identifier, staticRoute: true});
+ }
- this.index = opts.index || this.createIndexGenerator(opts);
-};
-
-_.extend(SiteMapManager.prototype, {
- createIndexGenerator: function () {
- return new IndexMapGenerator(_.pick(this, 'pages', 'posts', 'authors', 'tags'));
- },
-
- createPagesGenerator: function (opts) {
- return new PagesMapGenerator(opts);
- },
-
- createPostsGenerator: function (opts) {
- return new PostsMapGenerator(opts);
- },
-
- createUsersGenerator: function (opts) {
- return new UsersMapGenerator(opts);
- },
-
- createTagsGenerator: function (opts) {
- return new TagsMapGenerator(opts);
- },
-
- init: function () {
- var self = this,
- initOps = [
- this.pages.init(),
- this.posts.init(),
- this.authors.init(),
- this.tags.init()
- ];
-
- return Promise.all(initOps).then(function () {
- self.initialized = true;
+ if (router.name === 'CollectionRouter') {
+ this.pages.addUrl(router.getRoute({absolute: true}), {id: router.identifier, staticRoute: false});
+ }
});
- },
- getIndexXml: function () {
- if (!this.initialized) {
- return '';
- }
+ common.events.on('url.added', (obj) => {
+ this[obj.resource.config.type].addUrl(obj.url.absolute, obj.resource.data);
+ });
- return this.index.getIndexXml();
- },
-
- getSiteMapXml: function (type) {
- if (!this.initialized || !this[type]) {
- return null;
- }
-
- return this[type].siteMapContent;
+ common.events.on('url.removed', (obj) => {
+ this[obj.resource.config.type].removeUrl(obj.url.absolute, obj.resource.data);
+ });
}
-});
+
+ createIndexGenerator() {
+ return new IndexMapGenerator({
+ types: {
+ pages: this.pages,
+ posts: this.posts,
+ authors: this.authors,
+ tags: this.tags
+ }
+ });
+ }
+
+ createPagesGenerator(options) {
+ return new PagesMapGenerator(options);
+ }
+
+ createPostsGenerator(options) {
+ return new PostsMapGenerator(options);
+ }
+
+ createUsersGenerator(options) {
+ return new UsersMapGenerator(options);
+ }
+
+ createTagsGenerator(options) {
+ return new TagsMapGenerator(options);
+ }
+
+ getIndexXml() {
+ return this.index.getXml();
+ }
+
+ getSiteMapXml(type) {
+ return this[type].getXml();
+ }
+}
module.exports = SiteMapManager;
diff --git a/core/server/data/xml/sitemap/page-generator.js b/core/server/data/xml/sitemap/page-generator.js
index 9e04e640ca..746e491f5a 100644
--- a/core/server/data/xml/sitemap/page-generator.js
+++ b/core/server/data/xml/sitemap/page-generator.js
@@ -1,61 +1,22 @@
-var _ = require('lodash'),
- api = require('../../../api'),
- urlService = require('../../../services/url'),
+const _ = require('lodash'),
BaseMapGenerator = require('./base-generator');
-// A class responsible for generating a sitemap from posts and keeping it updated
-function PageMapGenerator(opts) {
- _.extend(this, opts);
+class PageMapGenerator extends BaseMapGenerator {
+ constructor(opts) {
+ super();
- BaseMapGenerator.apply(this, arguments);
+ this.name = 'pages';
+
+ _.extend(this, opts);
+ }
+
+ /**
+ * @TODO:
+ * We could influence this with priority or meta information
+ */
+ getPriorityForDatum(page) {
+ return page && page.staticRoute ? 1.0 : 0.8;
+ }
}
-// Inherit from the base generator class
-_.extend(PageMapGenerator.prototype, BaseMapGenerator.prototype);
-
-_.extend(PageMapGenerator.prototype, {
- bindEvents: function () {
- var self = this;
- this.dataEvents.on('page.published', self.addOrUpdateUrl.bind(self));
- this.dataEvents.on('page.published.edited', self.addOrUpdateUrl.bind(self));
- // Note: This is called if a published post is deleted
- this.dataEvents.on('page.unpublished', self.removeUrl.bind(self));
- },
-
- getData: function () {
- return api.posts.browse({
- context: {
- internal: true
- },
- filter: 'visibility:public',
- status: 'published',
- staticPages: true,
- limit: 'all'
- }).then(function (resp) {
- var homePage = {
- id: 0,
- name: 'home'
- };
- return [homePage].concat(resp.posts);
- });
- },
-
- validateDatum: function (datum) {
- return datum.name === 'home' || (datum.page === true && datum.visibility === 'public');
- },
-
- getUrlForDatum: function (post) {
- if (post.id === 0 && !_.isEmpty(post.name)) {
- return urlService.utils.urlFor(post.name, true);
- }
-
- return urlService.utils.urlFor('post', {post: post}, true);
- },
-
- getPriorityForDatum: function (post) {
- // TODO: We could influence this with priority or meta information
- return post && post.name === 'home' ? 1.0 : 0.8;
- }
-});
-
module.exports = PageMapGenerator;
diff --git a/core/server/data/xml/sitemap/post-generator.js b/core/server/data/xml/sitemap/post-generator.js
index 824cbd77bc..c7bc808ce6 100644
--- a/core/server/data/xml/sitemap/post-generator.js
+++ b/core/server/data/xml/sitemap/post-generator.js
@@ -1,58 +1,19 @@
-var _ = require('lodash'),
- api = require('../../../api'),
- urlService = require('../../../services/url'),
+const _ = require('lodash'),
BaseMapGenerator = require('./base-generator');
-// A class responsible for generating a sitemap from posts and keeping it updated
-function PostMapGenerator(opts) {
- _.extend(this, opts);
+class PostMapGenerator extends BaseMapGenerator {
+ constructor(opts) {
+ super();
- BaseMapGenerator.apply(this, arguments);
-}
+ this.name = 'posts';
-// Inherit from the base generator class
-_.extend(PostMapGenerator.prototype, BaseMapGenerator.prototype);
+ _.extend(this, opts);
+ }
-_.extend(PostMapGenerator.prototype, {
- bindEvents: function () {
- var self = this;
- this.dataEvents.on('post.published', self.addOrUpdateUrl.bind(self));
- this.dataEvents.on('post.published.edited', self.addOrUpdateUrl.bind(self));
- // Note: This is called if a published post is deleted
- this.dataEvents.on('post.unpublished', self.removeUrl.bind(self));
- },
-
- getData: function () {
- return api.posts.browse({
- context: {
- internal: true
- },
- filter: 'visibility:public',
- status: 'published',
- staticPages: false,
- limit: 'all',
- /**
- * Is required for permalinks e.g. `/:author/:slug/`.
- * @deprecated: `author`, will be removed in Ghost 2.0
- */
- include: 'author,tags'
- }).then(function (resp) {
- return resp.posts;
- });
- },
-
- validateDatum: function (datum) {
- return datum.page === false && datum.visibility === 'public';
- },
-
- getUrlForDatum: function (post) {
- return urlService.utils.urlFor('post', {post: post}, true);
- },
-
- getPriorityForDatum: function (post) {
+ getPriorityForDatum(post) {
// give a slightly higher priority to featured posts
return post.featured ? 0.9 : 0.8;
}
-});
+}
module.exports = PostMapGenerator;
diff --git a/core/server/data/xml/sitemap/tag-generator.js b/core/server/data/xml/sitemap/tag-generator.js
index 57499a6203..fd80e77bd5 100644
--- a/core/server/data/xml/sitemap/tag-generator.js
+++ b/core/server/data/xml/sitemap/tag-generator.js
@@ -1,50 +1,21 @@
-var _ = require('lodash'),
- api = require('../../../api'),
- urlService = require('../../../services/url'),
+const _ = require('lodash'),
BaseMapGenerator = require('./base-generator');
-// A class responsible for generating a sitemap from posts and keeping it updated
-function TagsMapGenerator(opts) {
- _.extend(this, opts);
+class TagsMapGenerator extends BaseMapGenerator {
+ constructor(opts) {
+ super();
- BaseMapGenerator.apply(this, arguments);
-}
+ this.name = 'tags';
+ _.extend(this, opts);
+ }
-// Inherit from the base generator class
-_.extend(TagsMapGenerator.prototype, BaseMapGenerator.prototype);
-
-_.extend(TagsMapGenerator.prototype, {
- bindEvents: function () {
- var self = this;
- this.dataEvents.on('tag.added', self.addOrUpdateUrl.bind(self));
- this.dataEvents.on('tag.edited', self.addOrUpdateUrl.bind(self));
- this.dataEvents.on('tag.deleted', self.removeUrl.bind(self));
- },
-
- getData: function () {
- return api.tags.browse({
- context: {
- internal: true
- },
- filter: 'visibility:public',
- limit: 'all'
- }).then(function (resp) {
- return resp.tags;
- });
- },
-
- validateDatum: function (datum) {
- return datum.visibility === 'public';
- },
-
- getUrlForDatum: function (tag) {
- return urlService.utils.urlFor('tag', {tag: tag}, true);
- },
-
- getPriorityForDatum: function () {
- // TODO: We could influence this with meta information
+ /**
+ * @TODO:
+ * We could influence this with priority or meta information
+ */
+ getPriorityForDatum() {
return 0.6;
}
-});
+}
module.exports = TagsMapGenerator;
diff --git a/core/server/data/xml/sitemap/user-generator.js b/core/server/data/xml/sitemap/user-generator.js
index 17e6a7adc6..974fe67961 100644
--- a/core/server/data/xml/sitemap/user-generator.js
+++ b/core/server/data/xml/sitemap/user-generator.js
@@ -1,58 +1,26 @@
-var _ = require('lodash'),
- api = require('../../../api'),
- urlService = require('../../../services/url'),
- validator = require('validator'),
- BaseMapGenerator = require('./base-generator'),
- // @TODO: figure out a way to get rid of this
- activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked'];
+const _ = require('lodash'),
+ validator = require('validator'),
+ BaseMapGenerator = require('./base-generator');
-// A class responsible for generating a sitemap from posts and keeping it updated
-function UserMapGenerator(opts) {
- _.extend(this, opts);
+class UserMapGenerator extends BaseMapGenerator {
+ constructor(opts) {
+ super();
- BaseMapGenerator.apply(this, arguments);
-}
+ this.name = 'authors';
+ _.extend(this, opts);
+ }
-// Inherit from the base generator class
-_.extend(UserMapGenerator.prototype, BaseMapGenerator.prototype);
-
-_.extend(UserMapGenerator.prototype, {
- bindEvents: function () {
- var self = this;
- this.dataEvents.on('user.activated', self.addOrUpdateUrl.bind(self));
- this.dataEvents.on('user.activated.edited', self.addOrUpdateUrl.bind(self));
- this.dataEvents.on('user.deactivated', self.removeUrl.bind(self));
- },
-
- getData: function () {
- return api.users.browse({
- context: {
- internal: true
- },
- filter: 'visibility:public',
- status: 'active',
- limit: 'all'
- }).then(function (resp) {
- return resp.users;
- });
- },
-
- validateDatum: function (datum) {
- return datum.visibility === 'public' && _.includes(activeStates, datum.status);
- },
-
- getUrlForDatum: function (user) {
- return urlService.utils.urlFor('author', {author: user}, true);
- },
-
- getPriorityForDatum: function () {
- // TODO: We could influence this with meta information
+ /**
+ * @TODO:
+ * We could influence this with priority or meta information
+ */
+ getPriorityForDatum() {
return 0.6;
- },
+ }
- validateImageUrl: function (imageUrl) {
+ validateImageUrl(imageUrl) {
return imageUrl && validator.isURL(imageUrl, {protocols: ['http', 'https'], require_protocol: true});
}
-});
+}
module.exports = UserMapGenerator;
diff --git a/core/server/helpers/author.js b/core/server/helpers/author.js
index 5119b93de9..0d6ce14e7b 100644
--- a/core/server/helpers/author.js
+++ b/core/server/helpers/author.js
@@ -9,13 +9,12 @@
//
// Block helper: `{{#author}}{{/author}}`
// This is the default handlebars behaviour of dropping into the author object scope
-
-var proxy = require('./proxy'),
+const proxy = require('./proxy'),
_ = require('lodash'),
+ urlService = require('../services/url'),
SafeString = proxy.SafeString,
handlebars = proxy.hbs.handlebars,
- templates = proxy.templates,
- url = proxy.url;
+ templates = proxy.templates;
/**
* @deprecated: will be removed in Ghost 2.0
@@ -25,13 +24,13 @@ module.exports = function author(options) {
return handlebars.helpers.with.call(this, this.author, options);
}
- var autolink = _.isString(options.hash.autolink) && options.hash.autolink === 'false' ? false : true,
- output = '';
+ const autolink = _.isString(options.hash.autolink) && options.hash.autolink === 'false' ? false : true;
+ let output = '';
if (this.author && this.author.name) {
if (autolink) {
output = templates.link({
- url: url.urlFor('author', {author: this.author}),
+ url: urlService.getUrlByResourceId(this.author.id),
text: _.escape(this.author.name)
});
} else {
diff --git a/core/server/helpers/authors.js b/core/server/helpers/authors.js
index 9e27ab7e4c..55dc13ad52 100644
--- a/core/server/helpers/authors.js
+++ b/core/server/helpers/authors.js
@@ -1,3 +1,4 @@
+'use strict';
// # Authors Helper
// Usage: `{{authors}}`, `{{authors separator=' - '}}`
//
@@ -5,32 +6,32 @@
// By default, authors are separated by commas.
//
// Note that the standard {{#each authors}} implementation is unaffected by this helper.
-
-var proxy = require('./proxy'),
+const proxy = require('./proxy'),
_ = require('lodash'),
+ urlService = require('../services/url'),
SafeString = proxy.SafeString,
templates = proxy.templates,
- url = proxy.url,
models = proxy.models;
module.exports = function authors(options) {
options = options || {};
options.hash = options.hash || {};
- var autolink = !(_.isString(options.hash.autolink) && options.hash.autolink === 'false'),
+ const autolink = !(_.isString(options.hash.autolink) && options.hash.autolink === 'false'),
separator = _.isString(options.hash.separator) ? options.hash.separator : ', ',
prefix = _.isString(options.hash.prefix) ? options.hash.prefix : '',
suffix = _.isString(options.hash.suffix) ? options.hash.suffix : '',
limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined,
+ visibilityArr = models.Base.Model.parseVisibilityString(options.hash.visibility);
+
+ let output = '',
from = options.hash.from ? parseInt(options.hash.from, 10) : 1,
- to = options.hash.to ? parseInt(options.hash.to, 10) : undefined,
- visibilityArr = models.Base.Model.parseVisibilityString(options.hash.visibility),
- output = '';
+ to = options.hash.to ? parseInt(options.hash.to, 10) : undefined;
function createAuthorsList(authors) {
function processAuthor(author) {
return autolink ? templates.link({
- url: url.urlFor('author', {author: author}),
+ url: urlService.getUrlByResourceId(author.id),
text: _.escape(author.name)
}) : _.escape(author.name);
}
diff --git a/core/server/helpers/img_url.js b/core/server/helpers/img_url.js
index c3882c2cd3..f047845924 100644
--- a/core/server/helpers/img_url.js
+++ b/core/server/helpers/img_url.js
@@ -8,7 +8,7 @@
// `absolute` flag outputs absolute URL, else URL is relative.
var proxy = require('./proxy'),
- url = proxy.url;
+ urlService = proxy.urlService;
module.exports = function imgUrl(attr, options) {
// CASE: if no attribute is passed, e.g. `{{img_url}}` we show a warning
@@ -27,7 +27,7 @@ module.exports = function imgUrl(attr, options) {
}
if (attr) {
- return url.urlFor('image', {image: attr}, absolute);
+ return urlService.utils.urlFor('image', {image: attr}, absolute);
}
// CASE: if you pass e.g. cover_image, but it is not set, then attr is null!
diff --git a/core/server/helpers/proxy.js b/core/server/helpers/proxy.js
index 4eb1a8b7e4..30415b699d 100644
--- a/core/server/helpers/proxy.js
+++ b/core/server/helpers/proxy.js
@@ -63,6 +63,6 @@ module.exports = {
// Various utils, needs cleaning up / simplifying
socialUrls: require('../lib/social/urls'),
blogIcon: require('../lib/image/blog-icon'),
- url: require('../services/url').utils,
+ urlService: require('../services/url'),
localUtils: require('./utils')
};
diff --git a/core/server/helpers/tags.js b/core/server/helpers/tags.js
index 411473af89..a158da009d 100644
--- a/core/server/helpers/tags.js
+++ b/core/server/helpers/tags.js
@@ -5,32 +5,32 @@
// By default, tags are separated by commas.
//
// Note that the standard {{#each tags}} implementation is unaffected by this helper
-
-var proxy = require('./proxy'),
+const proxy = require('./proxy'),
_ = require('lodash'),
+ urlService = proxy.urlService,
SafeString = proxy.SafeString,
templates = proxy.templates,
- url = proxy.url,
models = proxy.models;
module.exports = function tags(options) {
options = options || {};
options.hash = options.hash || {};
- var autolink = !(_.isString(options.hash.autolink) && options.hash.autolink === 'false'),
+ const autolink = !(_.isString(options.hash.autolink) && options.hash.autolink === 'false'),
separator = _.isString(options.hash.separator) ? options.hash.separator : ', ',
prefix = _.isString(options.hash.prefix) ? options.hash.prefix : '',
suffix = _.isString(options.hash.suffix) ? options.hash.suffix : '',
limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined,
+ visibilityArr = models.Base.Model.parseVisibilityString(options.hash.visibility);
+
+ let output = '',
from = options.hash.from ? parseInt(options.hash.from, 10) : 1,
- to = options.hash.to ? parseInt(options.hash.to, 10) : undefined,
- visibilityArr = models.Base.Model.parseVisibilityString(options.hash.visibility),
- output = '';
+ to = options.hash.to ? parseInt(options.hash.to, 10) : undefined;
function createTagList(tags) {
function processTag(tag) {
return autolink ? templates.link({
- url: url.urlFor('tag', {tag: tag}),
+ url: urlService.getUrlByResourceId(tag.id),
text: _.escape(tag.name)
}) : _.escape(tag.name);
}
diff --git a/core/server/index.js b/core/server/index.js
index 785fa0da5c..831cc8e2f3 100644
--- a/core/server/index.js
+++ b/core/server/index.js
@@ -68,8 +68,6 @@ function init() {
debug('Permissions done');
return Promise.join(
themes.init(),
- // Initialize apps
- apps.init(),
// Initialize xmrpc ping
xmlrpc.listen(),
// Initialize slack ping
@@ -89,6 +87,17 @@ function init() {
}
debug('Express Apps done');
+ }).then(function () {
+ /**
+ * @NOTE:
+ *
+ * Must happen after express app bootstrapping, because we need to ensure that all
+ * routers are created and are now ready to register additional routes. In this specific case, we
+ * are waiting that the AppRouter was instantiated. And then we can register e.g. amp if enabled.
+ *
+ * If you create a published post, the url is always stronger than any app url, which is equal.
+ */
+ return apps.init();
}).then(function () {
parentApp.use(auth.init());
debug('Auth done');
diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js
index 8300e845d0..12569da375 100644
--- a/core/server/models/base/index.js
+++ b/core/server/models/base/index.js
@@ -122,7 +122,10 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
if (!model.ghostEvents) {
model.ghostEvents = [];
- if (options.importing) {
+ // CASE: when importing or deleting content, lot's of model queries are happening in one transaction
+ // lot's of model events will be triggered. we ensure we set the max listeners to infinity.
+ // we are using `once` - we auto remove the listener afterwards
+ if (options.importing || options.destroyAll) {
options.transacting.setMaxListeners(0);
}
@@ -1045,7 +1048,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
relation: 'posts_tags',
condition: ['posts_tags.tag_id', '=', 'tags.id']
},
- select: ['posts_tags.post_id as post_id'],
+ select: ['posts_tags.post_id as post_id', 'tags.visibility'],
whereIn: 'posts_tags.post_id',
whereInKey: 'post_id',
orderBy: 'sort_order'
diff --git a/core/server/models/post.js b/core/server/models/post.js
index 4c3623b582..770b515405 100644
--- a/core/server/models/post.js
+++ b/core/server/models/post.js
@@ -396,30 +396,23 @@ Post = ghostBookshelf.Model.extend({
* But the model layer is complex and needs specific fields in specific situations.
*
* ### url generation
- * - it needs the following attrs for permalinks
+ * - @TODO: with dynamic routing, we no longer need default columns to fetch
+ * - because with static routing Ghost generated the url on runtime and needed the following attributes:
* - `slug`: /:slug/
* - `published_at`: /:year/:slug
* - `author_id`: /:author/:slug, /:primary_author/:slug
- * - @TODO: with channels, we no longer need these
- * - because the url service pre-generates urls based on the resources
+ * - now, the UrlService pre-generates urls based on the resources
* - you can ask `urlService.getUrlByResourceId(post.id)`
- * - @TODO: there is currently a bug in here
- * - you request `fields=title,url`
- * - you don't use `include=tags`
- * - your permalink is `/:primary_tag/:slug/`
- * - we won't fetch the primary tag, ends in `url = /all/my-slug/`
- * - will be auto fixed when merging channels
- * - url generator when using `findAll` or `findOne` doesn't work either when using e.g. `columns: title`
- * - this is because both functions don't make use of `defaultColumnsToFetch`
- * - will be auto fixed when merging channels
*
* ### events
* - you call `findAll` with `columns: id`
- * - then you trigger `post.save()`
+ * - then you trigger `post.save()` on the response
* - bookshelf events (`onSaving`) and model events (`emitChange`) are triggered
- * - @TODO: we need to disallow this
+ * - but you only fetched the id column, this will trouble (!), because the event hooks require more
+ * data than just the id
+ * - @TODO: we need to disallow this (!)
* - you should use `models.Post.edit(..)`
- * - editing resources denies `columns`
+ * - this disallows using the `columns` option
* - same for destroy - you should use `models.Post.destroy(...)`
*
* @IMPORTANT: This fn should **never** be used when updating models (models.Post.edit)!
@@ -466,7 +459,7 @@ Post = ghostBookshelf.Model.extend({
}
if (!options.columns || (options.columns && options.columns.indexOf('url') > -1)) {
- attrs.url = urlService.utils.urlPathForPost(attrs);
+ attrs.url = urlService.getUrlByResourceId(attrs.id);
}
if (oldPostId) {
@@ -577,7 +570,8 @@ Post = ghostBookshelf.Model.extend({
validOptions = {
findOne: ['columns', 'importing', 'withRelated', 'require'],
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'staticPages'],
- findAll: ['columns', 'filter']
+ findAll: ['columns', 'filter'],
+ destroy: ['destroyAll']
};
// The post model additionally supports having a formats option
diff --git a/core/server/models/tag.js b/core/server/models/tag.js
index a1b772c072..eefad7a744 100644
--- a/core/server/models/tag.js
+++ b/core/server/models/tag.js
@@ -88,7 +88,8 @@ Tag = ghostBookshelf.Model.extend({
validOptions = {
findPage: ['page', 'limit', 'columns', 'filter', 'order'],
findAll: ['columns'],
- findOne: ['visibility']
+ findOne: ['visibility'],
+ destroy: ['destroyAll']
};
if (validOptions[methodName]) {
diff --git a/core/server/services/apps/proxy.js b/core/server/services/apps/proxy.js
index 1016874acb..23e7d365e6 100644
--- a/core/server/services/apps/proxy.js
+++ b/core/server/services/apps/proxy.js
@@ -1,12 +1,15 @@
-var _ = require('lodash'),
+const _ = require('lodash'),
api = require('../../api'),
helpers = require('../../helpers/register'),
filters = require('../../filters'),
common = require('../../lib/common'),
- router = require('../route').appRouter,
- generateProxyFunctions;
+ routingService = require('../routing');
+
+let generateProxyFunctions;
generateProxyFunctions = function (name, permissions, isInternal) {
+ const appRouter = routingService.registry.getRouter('appRouter');
+
var getPermission = function (perm) {
return permissions[perm];
},
@@ -77,7 +80,7 @@ generateProxyFunctions = function (name, permissions, isInternal) {
// Expose the route service...
routeService: {
// This allows for mounting an entirely new Router at a path...
- registerRouter: checkRegisterPermissions('routes', router.mountRouter.bind(router))
+ registerRouter: checkRegisterPermissions('routes', appRouter.mountRouter.bind(appRouter))
},
// Mini proxy to the API - needs review
api: {
diff --git a/core/server/services/channels/Channel.js b/core/server/services/channels/Channel.js
deleted file mode 100644
index 816465807a..0000000000
--- a/core/server/services/channels/Channel.js
+++ /dev/null
@@ -1,62 +0,0 @@
-var _ = require('lodash'),
- defaultPostOptions = {};
-
-class Channel {
- constructor(name, options) {
- // Set the name
- this.name = name;
-
- // Store the originally passed in options
- this._origOptions = _.cloneDeep(options) || {};
-
- // Setup our route
- // @TODO should a channel have a route as part of the object? Or should this live elsewhere?
- this.route = this._origOptions.route ? this.translateRoute(this._origOptions.route) : '/';
-
- // Define context as name, plus any additional contexts, and don't allow duplicates
- this.context = _.union([this.name], this._origOptions.context);
-
- // DATA options
- // Options for fetching related posts
- this.postOptions = _.defaults({}, defaultPostOptions, this._origOptions.postOptions);
-
- // RESOURCE!!!
- // @TODO figure out a better way to declare relation to resource
- if (this._origOptions.data) {
- this.data = this._origOptions.data;
- }
-
- // Template options
- // @TODO fix these HORRIBLE names
- this.slugTemplate = !!this._origOptions.slugTemplate;
- if (this._origOptions.frontPageTemplate) {
- this.frontPageTemplate = this._origOptions.frontPageTemplate;
- }
-
- if (this._origOptions.editRedirect) {
- this.editRedirect = this._origOptions.editRedirect;
- }
- }
-
- get isPaged() {
- return _.has(this._origOptions, 'paged') ? this._origOptions.paged : true;
- }
-
- get hasRSS() {
- return _.has(this._origOptions, 'rss') ? this._origOptions.rss : true;
- }
-
- translateRoute(route) {
- const routeKeywords = {
- tag: 'tag',
- author: 'author'
- };
- // @TODO find this a more general / global home, as part of the Router system,
- // so that ALL routes that get registered WITH Ghost can do this
- return route.replace(/:t_([a-zA-Z]+)/, function (fullMatch, keyword) {
- return routeKeywords[keyword];
- });
- }
-}
-
-module.exports = Channel;
diff --git a/core/server/services/channels/Channels2.js b/core/server/services/channels/Channels2.js
deleted file mode 100644
index c1105bb946..0000000000
--- a/core/server/services/channels/Channels2.js
+++ /dev/null
@@ -1,165 +0,0 @@
-/* eslint-disable */
-
-const _ = require('lodash');
-const path = require('path');
-const EventEmitter = require('events').EventEmitter;
-const common = require('../../lib/common');
-const settingsCache = require('../settings/cache');
-
-/**
- * @temporary
- *
- * This is not designed yet. This is all temporary.
- */
-class RoutingType extends EventEmitter {
- constructor(obj) {
- super();
-
- this.route = _.defaults(obj.route, {value: null, extensions: {}});
- this.config = obj.config;
- }
-
- getRoute() {
- return this.route;
- }
-
- getPermalinks() {
- return false;
- }
-
- getType() {
- return this.config.type;
- }
-
- getFilter() {
- return this.config.options && this.config.options.filter;
- }
-
- toString() {
- return `Type: ${this.getType()}, Route: ${this.getRoute().value}`;
- }
-}
-
-class Collection extends RoutingType {
- constructor(obj) {
- super(obj);
-
- this.permalinks = _.defaults(obj.permalinks, {value: null, extensions: {}});
-
- this.permalinks.getValue = () => {
- /**
- * @deprecated Remove in Ghost 2.0
- */
- if (this.permalinks.value.match(/settings\.permalinks/)) {
- const value = this.permalinks.value.replace(/\/{settings\.permalinks}\//, settingsCache.get('permalinks'));
- return path.join(this.route.value, value);
- }
-
- return path.join(this.route.value, this.permalinks.value);
- };
-
- this._listeners();
- common.events.emit('routingType.created', this);
- }
-
- getPermalinks() {
- return this.permalinks;
- }
-
- _listeners() {
- /**
- * @deprecated Remove in Ghost 2.0
- */
- if (this.getPermalinks() && this.getPermalinks().value.match(/settings\.permalinks/)) {
- common.events.on('settings.permalinks.edited', () => {
- this.emit('updated');
- });
- }
- }
-
- toString() {
- return `Type: ${this.getType()}, Route: ${this.getRoute().value}, Permalinks: ${this.getPermalinks().value}`;
- }
-}
-
-class Taxonomy extends RoutingType {
- constructor(obj) {
- super(obj);
-
- this.permalinks = {value: '/:slug/', extensions: {}};
-
- this.permalinks.getValue = () => {
- return path.join(this.route.value, this.permalinks.value);
- };
-
- common.events.emit('routingType.created', this);
- }
-
- getPermalinks() {
- return this.permalinks;
- }
-
- toString() {
- return `Type: ${this.getType()}, Route: ${this.getRoute().value}, Permalinks: ${this.getPermalinks().value}`;
- }
-}
-
-class StaticPages extends RoutingType {
- constructor(obj) {
- super(obj);
-
- this.permalinks = {value: '/:slug/', extensions: {}};
-
- this.permalinks.getValue = () => {
- return path.join(this.route.value, this.permalinks.value);
- };
-
- common.events.emit('routingType.created', this);
- }
-
- getPermalinks() {
- return this.permalinks;
- }
-}
-
-const collection1 = new Collection({
- route: {
- value: '/'
- },
- permalinks: {
- value: '/{settings.permalinks}/'
- },
- config: {
- type: 'posts'
- }
-});
-
-const taxonomy1 = new Taxonomy({
- route: {
- value: '/author/'
- },
- config: {
- type: 'users',
- options: {}
- }
-});
-
-const taxonomy2 = new Taxonomy({
- route: {
- value: '/tag/'
- },
- config: {
- type: 'tags',
- options: {}
- }
-});
-
-const staticPages = new StaticPages({
- route: {
- value: '/'
- },
- config: {
- type: 'pages',
- options: {}
- }
-});
diff --git a/core/server/services/channels/config.channels.json b/core/server/services/channels/config.channels.json
deleted file mode 100644
index 9319e48902..0000000000
--- a/core/server/services/channels/config.channels.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "index": {
- "route": "/",
- "frontPageTemplate": "home"
- },
- "tag": {
- "route": "/:t_tag/:slug/",
- "postOptions": {
- "filter": "tags:'%s'+tags.visibility:public"
- },
- "data": {
- "tag": {
- "type": "read",
- "resource": "tags",
- "options": {
- "slug": "%s",
- "visibility": "public"
- }
- }
- },
- "slugTemplate": true,
- "editRedirect": "#/settings/tags/:slug/"
- },
- "author": {
- "route": "/:t_author/:slug/",
- "postOptions": {
- "filter": "authors:'%s'"
- },
- "data": {
- "author": {
- "type": "read",
- "resource": "users",
- "options": {
- "slug": "%s"
- }
- }
- },
- "slugTemplate": true,
- "editRedirect": "#/team/:slug/"
- }
-}
diff --git a/core/server/services/channels/index.js b/core/server/services/channels/index.js
deleted file mode 100644
index 7e85fecb42..0000000000
--- a/core/server/services/channels/index.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * # Channel Service
- *
- * The channel service is responsible for:
- * - maintaining the config of available Channels
- * - building out the logic of how an individual Channel works
- * - providing a top level router as an entry point
- *
- * Everything else exposed via the Channel object
- */
-
-// Exports the top-level router
-module.exports = require('./parent-router');
diff --git a/core/server/services/channels/loader.js b/core/server/services/channels/loader.js
deleted file mode 100644
index 407e424389..0000000000
--- a/core/server/services/channels/loader.js
+++ /dev/null
@@ -1,30 +0,0 @@
-var debug = require('ghost-ignition').debug('channels:loader'),
- _ = require('lodash'),
- path = require('path'),
- Channel = require('./Channel'),
- channels = [];
-
-function loadConfig() {
- var channelConfig = {};
-
- // This is a very dirty temporary hack so that we can test out channels with some Beta testers
- // If you are reading this code, and considering using it, best reach out to us on Slack
- // Definitely don't be angry at us if the structure of the JSON changes or this goes away.
- try {
- channelConfig = require(path.join(process.cwd(), 'config.channels.json'));
- } catch (err) {
- channelConfig = require('./config.channels.json');
- }
-
- return channelConfig;
-}
-
-module.exports.list = function list() {
- debug('Load channels start');
- _.each(loadConfig(), function (channelConfig, channelName) {
- channels.push(new Channel(channelName, channelConfig));
- });
-
- debug('Load channels end');
- return channels;
-};
diff --git a/core/server/services/channels/parent-router.js b/core/server/services/channels/parent-router.js
deleted file mode 100644
index a6853a8fe9..0000000000
--- a/core/server/services/channels/parent-router.js
+++ /dev/null
@@ -1,19 +0,0 @@
-var ParentRouter = require('../route').ParentRouter,
- loader = require('./loader'),
- channelRouter = require('./router');
-
-/**
- * Channels Router
- * Parent channels router will load & mount all routes when
- * .router() is called. This allows for reloading.
- */
-module.exports.router = function channelsRouter() {
- var channelsRouter = new ParentRouter('channels');
-
- loader.list().forEach(function (channel) {
- // Create a new channelRouter, and mount it onto the parent at the correct route
- channelsRouter.mountRouter(channel.route, channelRouter(channel));
- });
-
- return channelsRouter.router();
-};
diff --git a/core/server/services/channels/router.js b/core/server/services/channels/router.js
deleted file mode 100644
index 5522315350..0000000000
--- a/core/server/services/channels/router.js
+++ /dev/null
@@ -1,93 +0,0 @@
-var express = require('express'),
- _ = require('lodash'),
- common = require('../../lib/common'),
- urlService = require('../../services/url'),
- channelController = require('../../controllers/channel'),
- rssController = require('../../controllers/rss'),
- rssRouter,
- channelRouter;
-
-function handlePageParam(req, res, next, page) {
- // routeKeywords.page: 'page'
- var pageRegex = new RegExp('/page/(.*)?/'),
- rssRegex = new RegExp('/rss/(.*)?/');
-
- page = parseInt(page, 10);
-
- if (page === 1) {
- // Page 1 is an alias, do a permanent 301 redirect
- if (rssRegex.test(req.url)) {
- return urlService.utils.redirect301(res, req.originalUrl.replace(rssRegex, '/rss/'));
- } else {
- return urlService.utils.redirect301(res, req.originalUrl.replace(pageRegex, '/'));
- }
- } else if (page < 1 || isNaN(page)) {
- // Nothing less than 1 is a valid page number, go straight to a 404
- return next(new common.errors.NotFoundError({message: common.i18n.t('errors.errors.pageNotFound')}));
- } else {
- // Set req.params.page to the already parsed number, and continue
- req.params.page = page;
- return next();
- }
-}
-
-function rssConfigMiddleware(req, res, next) {
- res.locals.channel.isRSS = true;
- next();
-}
-
-function channelConfigMiddleware(channel) {
- return function doChannelConfig(req, res, next) {
- res.locals.channel = _.cloneDeep(channel);
- // @TODO refactor into to something explicit
- res._route = {type: 'channel'};
- next();
- };
-}
-
-rssRouter = function rssRouter(channelMiddleware) {
- // @TODO move this to an RSS module
- var router = express.Router({mergeParams: true}),
- baseRoute = '/rss/',
- pageRoute = urlService.utils.urlJoin(baseRoute, ':page(\\d+)/');
-
- // @TODO figure out how to collapse this into a single rule
- router.get(baseRoute, channelMiddleware, rssConfigMiddleware, rssController);
- router.get(pageRoute, channelMiddleware, rssConfigMiddleware, rssController);
- // Extra redirect rule
- router.get('/feed/', function redirectToRSS(req, res) {
- return urlService.utils.redirect301(res, urlService.utils.urlJoin(urlService.utils.getSubdir(), req.baseUrl, baseRoute));
- });
-
- router.param('page', handlePageParam);
- return router;
-};
-
-channelRouter = function channelRouter(channel) {
- var channelRouter = express.Router({mergeParams: true}),
- baseRoute = '/',
- // routeKeywords.page: 'page'
- pageRoute = urlService.utils.urlJoin('/page', ':page(\\d+)/'),
- middleware = [channelConfigMiddleware(channel)];
-
- channelRouter.get(baseRoute, middleware, channelController);
-
- if (channel.isPaged) {
- channelRouter.param('page', handlePageParam);
- channelRouter.get(pageRoute, middleware, channelController);
- }
-
- if (channel.hasRSS) {
- channelRouter.use(rssRouter(middleware));
- }
-
- if (channel.editRedirect) {
- channelRouter.get('/edit/', function redirect(req, res) {
- urlService.utils.redirectToAdmin(302, res, channel.editRedirect.replace(':slug', req.params.slug));
- });
- }
-
- return channelRouter;
-};
-
-module.exports = channelRouter;
diff --git a/core/server/services/route/app-router.js b/core/server/services/route/app-router.js
deleted file mode 100644
index 66a63cf3ff..0000000000
--- a/core/server/services/route/app-router.js
+++ /dev/null
@@ -1,4 +0,0 @@
-var ParentRouter = require('./ParentRouter'),
- appRouter = new ParentRouter('apps');
-
-module.exports = appRouter;
diff --git a/core/server/services/route/index.js b/core/server/services/route/index.js
deleted file mode 100644
index 864d5611f2..0000000000
--- a/core/server/services/route/index.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * # Route Service
- *
- * Note: routes are patterns, not individual URLs, which have either
- * subrouters, or controllers mounted on them. There are not that many routes.
- *
- * The route service is intended to:
- * - keep track of the registered routes, and what they have mounted on them
- * - provide a way for apps to register routes
- * - expose base classes & registry to the rest of Ghost
- */
-
-// This is the main router, that gets extended & mounted /site
-module.exports.siteRouter = require('./site-router');
-
-// We expose this via the App Proxy, so that Apps can register routes
-module.exports.appRouter = require('./app-router');
-
-// Classes for other parts of Ghost to extend
-module.exports.ParentRouter = require('./ParentRouter');
-
-module.exports.registry = require('./registry');
diff --git a/core/server/services/route/registry.js b/core/server/services/route/registry.js
deleted file mode 100644
index df92c12fd3..0000000000
--- a/core/server/services/route/registry.js
+++ /dev/null
@@ -1,12 +0,0 @@
-var _ = require('lodash'),
- routes = [];
-
-module.exports = {
- set(routerName, route) {
- routes.push({route: route, from: routerName});
- },
-
- getAll() {
- return _.cloneDeep(routes);
- }
-};
diff --git a/core/server/services/route/site-router.js b/core/server/services/route/site-router.js
deleted file mode 100644
index e4308a53af..0000000000
--- a/core/server/services/route/site-router.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// Site Router is the top level Router for the whole site
-var ParentRouter = require('./ParentRouter'),
- siteRouter = new ParentRouter('site');
-
-module.exports = siteRouter;
diff --git a/core/server/services/routing/CollectionRouter.js b/core/server/services/routing/CollectionRouter.js
new file mode 100644
index 0000000000..60a1e1e1f7
--- /dev/null
+++ b/core/server/services/routing/CollectionRouter.js
@@ -0,0 +1,157 @@
+const debug = require('ghost-ignition').debug('services:routing:collection-router');
+const common = require('../../lib/common');
+const settingsCache = require('../settings/cache');
+const urlService = require('../url');
+const ParentRouter = require('./ParentRouter');
+
+const controllers = require('./controllers');
+const middlewares = require('./middlewares');
+const RSSRouter = require('./RSSRouter');
+
+class CollectionRouter extends ParentRouter {
+ constructor(indexRoute, object) {
+ super('CollectionRouter');
+
+ // NOTE: index/parent route e.g. /, /podcast/, /magic/ ;)
+ this.route = {
+ value: indexRoute
+ };
+
+ this.permalinks = {
+ originalValue: object.permalink,
+ value: object.permalink
+ };
+
+ this.templates = object.template || [];
+
+ this.filter = object.filter || 'page:false';
+
+ /**
+ * @deprecated Remove in Ghost 2.0
+ */
+ if (this.permalinks.originalValue.match(/globals\.permalinks/)) {
+ this.permalinks.originalValue = this.permalinks.originalValue.replace('{globals.permalinks}', '{settings.permalinks}');
+ this.permalinks.value = this.permalinks.originalValue.replace('{settings.permalinks}', settingsCache.get('permalinks'));
+ this.permalinks.value = urlService.utils.deduplicateDoubleSlashes(this.permalinks.value);
+ }
+
+ this.permalinks.getValue = (options) => {
+ options = options || {};
+
+ // @NOTE: url options are only required when registering urls in express.
+ // e.g. the UrlService will access the routes and doesn't want to know about possible url options
+ if (options.withUrlOptions) {
+ return urlService.utils.urlJoin(this.permalinks.value, '/:options(edit)?/');
+ }
+
+ return this.permalinks.value;
+ };
+
+ debug(this.route, this.permalinks);
+
+ this._registerRoutes();
+ this._listeners();
+ }
+
+ _registerRoutes() {
+ // REGISTER: context middleware for this collection
+ this.router().use(this._prepareIndexContext.bind(this));
+
+ // REGISTER: collection route e.g. /, /podcast/
+ this.mountRoute(this.route.value, controllers.collection);
+
+ // REGISTER: enable pagination by default
+ this.router().param('page', middlewares.pageParam);
+ this.mountRoute(urlService.utils.urlJoin(this.route.value, 'page', ':page(\\d+)'), controllers.collection);
+
+ this.rssRouter = new RSSRouter();
+
+ // REGISTER: enable rss by default
+ this.mountRouter(this.route.value, this.rssRouter.router());
+
+ // REGISTER: context middleware for entries
+ this.router().use(this._prepareEntryContext.bind(this));
+
+ // REGISTER: permalinks e.g. /:slug/, /podcast/:slug
+ this.mountRoute(this.permalinks.getValue({withUrlOptions: true}), controllers.entry);
+
+ common.events.emit('router.created', this);
+ }
+
+ /**
+ * We attach context information of the router to the request.
+ * By this we can e.g. access the router options in controllers.
+ *
+ * @TODO: Why do we need two context objects? O_O - refactor this out
+ */
+ _prepareIndexContext(req, res, next) {
+ res.locals.routerOptions = {
+ filter: this.filter,
+ permalinks: this.permalinks.getValue({withUrlOptions: true}),
+ type: this.getType(),
+ context: ['home'],
+ frontPageTemplate: 'home',
+ templates: this.templates.reverse(),
+ identifier: this.identifier
+ };
+
+ res._route = {
+ type: 'collection'
+ };
+
+ next();
+ }
+
+ _prepareEntryContext(req, res, next) {
+ res.locals.routerOptions.context = ['post'];
+ res._route.type = 'entry';
+ next();
+ }
+
+ _listeners() {
+ /**
+ * @deprecated Remove in Ghost 2.0
+ */
+ if (this.getPermalinks() && this.getPermalinks().originalValue.match(/settings\.permalinks/)) {
+ this._onPermalinksEditedListener = this._onPermalinksEdited.bind(this);
+ common.events.on('settings.permalinks.edited', this._onPermalinksEditedListener);
+ }
+ }
+
+ /**
+ * We unmount and mount the permalink url. This enables the ability to change urls on runtime.
+ */
+ _onPermalinksEdited() {
+ this.unmountRoute(this.permalinks.getValue({withUrlOptions: true}));
+
+ this.permalinks.value = this.permalinks.originalValue.replace('{settings.permalinks}', settingsCache.get('permalinks'));
+ this.permalinks.value = urlService.utils.deduplicateDoubleSlashes(this.permalinks.value);
+
+ this.mountRoute(this.permalinks.getValue({withUrlOptions: true}), controllers.entry);
+ this.emit('updated');
+ }
+
+ getType() {
+ return 'posts';
+ }
+
+ getRoute(options) {
+ options = options || {};
+
+ return urlService.utils.createUrl(this.route.value, options.absolute, options.secure);
+ }
+
+ getRssUrl(options) {
+ return urlService.utils.createUrl(urlService.utils.urlJoin(this.route.value, this.rssRouter.route.value), options.absolute, options.secure);
+ }
+
+ reset() {
+ if (!this._onPermalinksEditedListener) {
+ return;
+ }
+
+ common.events.removeListener('settings.permalinks.edited', this._onPermalinksEditedListener);
+ }
+}
+
+module.exports = CollectionRouter;
diff --git a/core/server/services/route/ParentRouter.js b/core/server/services/routing/ParentRouter.js
similarity index 50%
rename from core/server/services/route/ParentRouter.js
rename to core/server/services/routing/ParentRouter.js
index 4c959ab4e3..5960a32f54 100644
--- a/core/server/services/route/ParentRouter.js
+++ b/core/server/services/routing/ParentRouter.js
@@ -6,15 +6,24 @@
* Only allows for .use and .get at the moment - we don't have clear use-cases for anything else yet.
*/
-var debug = require('ghost-ignition').debug('services:routes:ParentRouter'),
+const debug = require('ghost-ignition').debug('services:routing:ParentRouter'),
+ EventEmitter = require('events').EventEmitter,
express = require('express'),
+ _ = require('lodash'),
+ security = require('../../lib/security'),
+ urlService = require('../url'),
// This the route registry for the whole site
registry = require('./registry');
+
/**
* We expose a very limited amount of express.Router via specialist methods
*/
-class ParentRouter {
+class ParentRouter extends EventEmitter {
constructor(name) {
+ super();
+
+ this.identifier = security.identifier.uid(10);
+
this.name = name;
this._router = express.Router({mergeParams: true});
}
@@ -25,7 +34,7 @@ class ParentRouter {
debug(this.name + ': mountRouter: ' + router.name);
this._router.use(router);
} else {
- registry.set(this.name, path);
+ registry.setRoute(this.name, path);
debug(this.name + ': mountRouter: ' + router.name + ' at ' + path);
this._router.use(path, router);
}
@@ -33,15 +42,49 @@ class ParentRouter {
mountRoute(path, controller) {
debug(this.name + ': mountRoute for', path, controller.name);
- registry.set(this.name, path);
+ registry.setRoute(this.name, path);
this._router.get(path, controller);
}
+ unmountRoute(path) {
+ let indexToRemove = null;
+
+ _.each(this._router.stack, (item, index) => {
+ if (item.path === path) {
+ indexToRemove = index;
+ }
+ });
+
+ if (indexToRemove !== null) {
+ this._router.stack.splice(indexToRemove, 1);
+ }
+ }
+
router() {
// @TODO: should this just be the handler that is returned?
// return this._router.handle.bind(this._router);
return this._router;
}
+
+ getPermalinks() {
+ return this.permalinks;
+ }
+
+ getFilter() {
+ return this.filter;
+ }
+
+ /**
+ * Will return the full route including subdirectory.
+ * Do not use this function to mount routes for now, because the subdirectory is already mounted.
+ */
+ getRoute(options) {
+ options = options || {};
+
+ return urlService.utils.createUrl(this.route.value, options.absolute, options.secure);
+ }
+
+ reset() {}
}
module.exports = ParentRouter;
diff --git a/core/server/services/routing/PreviewRouter.js b/core/server/services/routing/PreviewRouter.js
new file mode 100644
index 0000000000..52bdc29cee
--- /dev/null
+++ b/core/server/services/routing/PreviewRouter.js
@@ -0,0 +1,29 @@
+const ParentRouter = require('./ParentRouter');
+const urlService = require('../url');
+const controllers = require('./controllers');
+
+class PreviewRouter extends ParentRouter {
+ constructor() {
+ super('PreviewRouter');
+
+ this.route = {value: '/p/'};
+
+ this._registerRoutes();
+ }
+
+ _registerRoutes() {
+ this.router().use(this._prepareContext.bind(this));
+
+ this.mountRoute(urlService.utils.urlJoin(this.route.value, ':uuid', ':options?'), controllers.preview);
+ }
+
+ _prepareContext(req, res, next) {
+ res._route = {
+ type: 'entry'
+ };
+
+ next();
+ }
+}
+
+module.exports = PreviewRouter;
diff --git a/core/server/services/routing/RSSRouter.js b/core/server/services/routing/RSSRouter.js
new file mode 100644
index 0000000000..79131cae72
--- /dev/null
+++ b/core/server/services/routing/RSSRouter.js
@@ -0,0 +1,36 @@
+const ParentRouter = require('./ParentRouter');
+const urlService = require('../url');
+
+const controllers = require('./controllers');
+const middlewares = require('./middlewares');
+
+class RSSRouter extends ParentRouter {
+ constructor() {
+ super('RSSRouter');
+
+ this.route = {value: '/rss/'};
+ this._registerRoutes();
+ }
+
+ _registerRoutes() {
+ this.mountRoute(this.route.value, controllers.rss);
+
+ // REGISTER: pagination
+ this.router().param('page', middlewares.pageParam);
+ this.mountRoute(urlService.utils.urlJoin(this.route.value, ':page(\\d+)'), controllers.rss);
+
+ // REGISTER: redirect rule
+ this.mountRoute('/feed/', this._redirectFeedRequest.bind(this));
+ }
+
+ _redirectFeedRequest(req, res) {
+ urlService
+ .utils
+ .redirect301(
+ res,
+ urlService.utils.urlJoin(urlService.utils.getSubdir(), req.baseUrl, this.route.value)
+ );
+ }
+}
+
+module.exports = RSSRouter;
diff --git a/core/server/services/routing/StaticPagesRouter.js b/core/server/services/routing/StaticPagesRouter.js
new file mode 100644
index 0000000000..6e827b5f08
--- /dev/null
+++ b/core/server/services/routing/StaticPagesRouter.js
@@ -0,0 +1,60 @@
+const debug = require('ghost-ignition').debug('services:routing:static-pages-router');
+const ParentRouter = require('./ParentRouter');
+const controllers = require('./controllers');
+const common = require('../../lib/common');
+
+class StaticPagesRouter extends ParentRouter {
+ constructor() {
+ super('StaticPagesRouter');
+
+ this.permalinks = {
+ value: '/:slug/'
+ };
+
+ this.filter = 'page:true';
+
+ this.permalinks.getValue = () => {
+ return this.permalinks.value;
+ };
+
+ debug(this.permalinks);
+
+ this._registerRoutes();
+ }
+
+ _registerRoutes() {
+ this.router().use(this._prepareContext.bind(this));
+
+ // REGISTER: permalink for static pages
+ this.mountRoute(this.permalinks.getValue(), controllers.entry);
+
+ common.events.emit('router.created', this);
+ }
+
+ _prepareContext(req, res, next) {
+ res.locals.routerOptions = {
+ filter: this.filter,
+ permalinks: this.permalinks.getValue(),
+ type: this.getType(),
+ context: ['page']
+ };
+
+ res._route = {
+ type: 'entry'
+ };
+
+ next();
+ }
+
+ getType() {
+ return 'pages';
+ }
+
+ getRoute() {
+ return null;
+ }
+
+ reset() {}
+}
+
+module.exports = StaticPagesRouter;
diff --git a/core/server/services/routing/StaticRoutesRouter.js b/core/server/services/routing/StaticRoutesRouter.js
new file mode 100644
index 0000000000..966d0b0f6a
--- /dev/null
+++ b/core/server/services/routing/StaticRoutesRouter.js
@@ -0,0 +1,46 @@
+const debug = require('ghost-ignition').debug('services:routing:static-pages-router');
+const common = require('../../lib/common');
+const helpers = require('./helpers');
+const ParentRouter = require('./ParentRouter');
+
+class StaticRoutesRouter extends ParentRouter {
+ constructor(key, template) {
+ super('StaticRoutesRouter');
+
+ this.route = {value: key};
+ this.template = template;
+
+ debug(this.route.value, this.template);
+
+ this._registerRoutes();
+ }
+
+ _registerRoutes() {
+ this.router().use(this._prepareContext.bind(this));
+
+ this.mountRoute(this.route.value, this._renderStaticRoute.bind(this));
+
+ common.events.emit('router.created', this);
+ }
+
+ _prepareContext(req, res, next) {
+ res._route = {
+ type: 'custom',
+ templateName: this.template,
+ defaultTemplate: 'index'
+ };
+
+ res.locals.routerOptions = {
+ context: []
+ };
+
+ next();
+ }
+
+ _renderStaticRoute(req, res) {
+ debug('StaticRoutesRouter');
+ helpers.renderer(req, res, {});
+ }
+}
+
+module.exports = StaticRoutesRouter;
diff --git a/core/server/services/routing/TaxonomyRouter.js b/core/server/services/routing/TaxonomyRouter.js
new file mode 100644
index 0000000000..3d7961a43e
--- /dev/null
+++ b/core/server/services/routing/TaxonomyRouter.js
@@ -0,0 +1,108 @@
+const debug = require('ghost-ignition').debug('services:routing:taxonomy-router');
+const common = require('../../lib/common');
+const ParentRouter = require('./ParentRouter');
+const RSSRouter = require('./RSSRouter');
+const urlService = require('../url');
+const controllers = require('./controllers');
+const middlewares = require('./middlewares');
+
+/* eslint-disable */
+const knownTaxonomies = {
+ tag: {
+ filter: "tags:'%s'+tags.visibility:public",
+ data: {
+ type: 'read',
+ resource: 'tags',
+ options: {
+ slug: '%s',
+ visibility: 'public'
+ }
+ },
+ editRedirect: '#/settings/tags/:slug/'
+ },
+ author: {
+ filter: "authors:'%s'",
+ data: {
+ type: 'read',
+ resource: 'users',
+ options: {
+ slug: '%s',
+ visibility: 'public'
+ }
+ },
+ editRedirect: '#/team/:slug/'
+ }
+};
+/* eslint-enable */
+
+class TaxonomyRouter extends ParentRouter {
+ constructor(key, permalinks) {
+ super('Taxonomy');
+
+ this.taxonomyKey = key;
+
+ this.permalinks = {
+ value: permalinks
+ };
+
+ this.permalinks.getValue = () => {
+ return this.permalinks.value;
+ };
+
+ debug(this.permalinks);
+
+ this._registerRoutes();
+ }
+
+ _registerRoutes() {
+ // REGISTER: context middleware
+ this.router().use(this._prepareContext.bind(this));
+
+ // REGISTER: enable rss by default
+ this.mountRouter(this.permalinks.getValue(), new RSSRouter().router());
+
+ // REGISTER: e.g. /tag/:slug/
+ this.mountRoute(this.permalinks.getValue(), controllers.collection);
+
+ // REGISTER: enable pagination for each taxonomy by default
+ this.router().param('page', middlewares.pageParam);
+ this.mountRoute(urlService.utils.urlJoin(this.permalinks.value, 'page', ':page(\\d+)'), controllers.collection);
+
+ this.mountRoute(urlService.utils.urlJoin(this.permalinks.value, 'edit'), this._redirectEditOption.bind(this));
+
+ common.events.emit('router.created', this);
+ }
+
+ _prepareContext(req, res, next) {
+ res.locals.routerOptions = {
+ name: this.taxonomyKey,
+ permalinks: this.permalinks.getValue(),
+ data: {[this.taxonomyKey]: knownTaxonomies[this.taxonomyKey].data},
+ filter: knownTaxonomies[this.taxonomyKey].filter,
+ type: this.getType(),
+ context: [this.taxonomyKey],
+ slugTemplate: true,
+ identifier: this.identifier
+ };
+
+ res._route = {
+ type: 'collection'
+ };
+
+ next();
+ }
+
+ _redirectEditOption(req, res) {
+ urlService.utils.redirectToAdmin(302, res, knownTaxonomies[this.taxonomyKey].editRedirect.replace(':slug', req.params.slug));
+ }
+
+ getType() {
+ return knownTaxonomies[this.taxonomyKey].data.resource;
+ }
+
+ getRoute() {
+ return null;
+ }
+}
+
+module.exports = TaxonomyRouter;
diff --git a/core/server/services/routing/bootstrap.js b/core/server/services/routing/bootstrap.js
new file mode 100644
index 0000000000..217a48f2e1
--- /dev/null
+++ b/core/server/services/routing/bootstrap.js
@@ -0,0 +1,68 @@
+const debug = require('ghost-ignition').debug('services:routing:bootstrap');
+const _ = require('lodash');
+const settingsService = require('../settings');
+const StaticRoutesRouter = require('./StaticRoutesRouter');
+const StaticPagesRouter = require('./StaticPagesRouter');
+const CollectionRouter = require('./CollectionRouter');
+const TaxonomyRouter = require('./TaxonomyRouter');
+const PreviewRouter = require('./PreviewRouter');
+const ParentRouter = require('./ParentRouter');
+
+const registry = require('./registry');
+
+/**
+ * Create a set of default and dynamic routers defined in the routing yaml.
+ *
+ * @TODO:
+ * - is the PreviewRouter an app?
+ */
+module.exports = function bootstrap() {
+ debug('bootstrap');
+
+ registry.resetAllRouters();
+ registry.resetAllRoutes();
+
+ const siteRouter = new ParentRouter('site');
+ const previewRouter = new PreviewRouter();
+
+ siteRouter.mountRouter(previewRouter.router());
+
+ registry.setRouter('siteRouter', siteRouter);
+ registry.setRouter('previewRouter', previewRouter);
+
+ const dynamicRoutes = settingsService.get('routes');
+
+ _.each(dynamicRoutes.taxonomies, (value, key) => {
+ const taxonomyRouter = new TaxonomyRouter(key, value);
+ siteRouter.mountRouter(taxonomyRouter.router());
+
+ registry.setRouter(taxonomyRouter.identifier, taxonomyRouter);
+ });
+
+ _.each(dynamicRoutes.routes, (value, key) => {
+ const staticRoutesRouter = new StaticRoutesRouter(key, value);
+ siteRouter.mountRouter(staticRoutesRouter.router());
+
+ registry.setRouter(staticRoutesRouter.identifier, staticRoutesRouter);
+ });
+
+ _.each(dynamicRoutes.collections, (value, key) => {
+ const collectionRouter = new CollectionRouter(key, value);
+ siteRouter.mountRouter(collectionRouter.router());
+
+ registry.setRouter(collectionRouter.identifier, collectionRouter);
+ });
+
+ const staticPagesRouter = new StaticPagesRouter();
+ siteRouter.mountRouter(staticPagesRouter.router());
+
+ registry.setRouter('staticPagesRouter', staticPagesRouter);
+
+ const appRouter = new ParentRouter('apps');
+ siteRouter.mountRouter(appRouter.router());
+
+ registry.setRouter('appRouter', appRouter);
+
+ debug('Routes:', registry.getAllRoutes());
+ return siteRouter.router();
+};
diff --git a/core/server/services/routing/controllers/collection.js b/core/server/services/routing/controllers/collection.js
new file mode 100644
index 0000000000..48f5ebd49c
--- /dev/null
+++ b/core/server/services/routing/controllers/collection.js
@@ -0,0 +1,54 @@
+const _ = require('lodash'),
+ debug = require('ghost-ignition').debug('services:routing:controllers:collection'),
+ common = require('../../../lib/common'),
+ security = require('../../../lib/security'),
+ themes = require('../../themes'),
+ filters = require('../../../filters'),
+ helpers = require('../helpers');
+
+module.exports = function collectionController(req, res, next) {
+ debug('collectionController', req.params, res.locals.routerOptions);
+
+ const pathOptions = {
+ page: req.params.page !== undefined ? req.params.page : 1,
+ slug: req.params.slug ? security.string.safe(req.params.slug) : undefined
+ };
+
+ if (pathOptions.page) {
+ const postsPerPage = parseInt(themes.getActive().config('posts_per_page'));
+
+ // CASE: no negative posts per page
+ if (!isNaN(postsPerPage) && postsPerPage > 0) {
+ pathOptions.limit = postsPerPage;
+ }
+ }
+
+ return helpers.fetchData(pathOptions, res.locals.routerOptions)
+ .then(function handleResult(result) {
+ // CASE: requested page is greater than number of pages we have
+ if (pathOptions.page > result.meta.pagination.pages) {
+ return next(new common.errors.NotFoundError({
+ message: common.i18n.t('errors.errors.pageNotFound')
+ }));
+ }
+
+ // Format data 1
+ // @TODO: figure out if this can be removed, it's supposed to ensure that absolutely URLs get generated
+ // correctly for the various objects, but I believe it doesn't work and a different approach is needed.
+ helpers.secure(req, result.posts);
+
+ // @TODO: get rid of this O_O
+ _.each(result.data, function (data) {
+ helpers.secure(req, data);
+ });
+
+ // @TODO: properly design these filters
+ filters.doFilter('prePostsRender', result.posts, res.locals)
+ .then(function (posts) {
+ result.posts = posts;
+ return result;
+ })
+ .then(helpers.renderCollection(req, res));
+ })
+ .catch(helpers.handleError(next));
+};
diff --git a/core/server/services/routing/controllers/entry.js b/core/server/services/routing/controllers/entry.js
new file mode 100644
index 0000000000..7fcd416d4f
--- /dev/null
+++ b/core/server/services/routing/controllers/entry.js
@@ -0,0 +1,78 @@
+const debug = require('ghost-ignition').debug('services:routing:controllers:entry'),
+ urlService = require('../../url'),
+ filters = require('../../../filters'),
+ helpers = require('../helpers');
+
+/**
+ * @TODO:
+ * - use `filter` for `findOne`?
+ * - always execute `next` until no router want's to serve and 404's
+ */
+module.exports = function entryController(req, res, next) {
+ debug('entryController', res.locals.routerOptions);
+
+ return helpers.postLookup(req.path, res.locals.routerOptions)
+ .then(function then(lookup) {
+ // Format data 1
+ const post = lookup ? lookup.post : false;
+
+ if (!post) {
+ debug('no post');
+ return next();
+ }
+
+ // CASE: postlookup can detect options for example /edit, unknown options get ignored and end in 404
+ if (lookup.isUnknownOption) {
+ debug('isUnknownOption');
+ return next();
+ }
+
+ // CASE: last param is of url is /edit, redirect to admin
+ if (lookup.isEditURL) {
+ debug('redirect. is edit url');
+ return urlService.utils.redirectToAdmin(302, res, '/editor/' + post.id);
+ }
+
+ /**
+ * CASE: check if type of router owns this resource
+ *
+ * Static pages have a hardcoded permalink, which is `/:slug/`.
+ * Imagine you define a collection under `/` with the permalink `/:slug/`.
+ *
+ * The router hierarchy is:
+ *
+ * 1. collections
+ * 2. static pages
+ *
+ * Both permalinks are registered in express. If you serve a static page, the
+ * collection router will try to serve this as a post resource.
+ *
+ * That's why we have to check against the router type.
+ */
+ if (urlService.getResource(post.url).config.type !== res.locals.routerOptions.type) {
+ debug('not my resource type');
+ return next();
+ }
+
+ /**
+ * CASE: Permalink is not valid anymore, we redirect him permanently to the correct one
+ * This should only happen if you have date permalinks enabled and you change
+ * your publish date.
+ *
+ * @NOTE
+ *
+ * The resource url always contains the subdirectory. This was different before dynamic routing.
+ * That's why we have to use the original url, which contains the sub-directory.
+ */
+ if (post.url !== req.originalUrl) {
+ debug('redirect');
+ return urlService.utils.redirect301(res, post.url);
+ }
+
+ helpers.secure(req, post);
+
+ filters.doFilter('prePostsRender', post, res.locals)
+ .then(helpers.renderEntry(req, res));
+ })
+ .catch(helpers.handleError(next));
+};
diff --git a/core/server/services/routing/controllers/index.js b/core/server/services/routing/controllers/index.js
new file mode 100644
index 0000000000..852140aaef
--- /dev/null
+++ b/core/server/services/routing/controllers/index.js
@@ -0,0 +1,17 @@
+module.exports = {
+ get entry() {
+ return require('./entry');
+ },
+
+ get collection() {
+ return require('./collection');
+ },
+
+ get rss() {
+ return require('./rss');
+ },
+
+ get preview() {
+ return require('./preview');
+ }
+};
diff --git a/core/server/services/routing/controllers/preview.js b/core/server/services/routing/controllers/preview.js
new file mode 100644
index 0000000000..2adb779d6c
--- /dev/null
+++ b/core/server/services/routing/controllers/preview.js
@@ -0,0 +1,42 @@
+const debug = require('ghost-ignition').debug('services:routing:controllers:preview'),
+ api = require('../../../api'),
+ urlService = require('../../url'),
+ filters = require('../../../filters'),
+ helpers = require('../helpers');
+
+module.exports = function previewController(req, res, next) {
+ debug('previewController');
+
+ const params = {
+ uuid: req.params.uuid,
+ status: 'all',
+ include: 'author,authors,tags'
+ };
+
+ api.posts.read(params)
+ .then(function then(result) {
+ const post = result.posts[0];
+
+ if (!post) {
+ return next();
+ }
+
+ if (req.params.options && req.params.options.toLowerCase() === 'edit') {
+ // CASE: last param of the url is /edit, redirect to admin
+ return urlService.utils.redirectToAdmin(302, res, '/editor/' + post.id);
+ } else if (req.params.options) {
+ // CASE: unknown options param detected, ignore
+ return next();
+ }
+
+ if (post.status === 'published') {
+ return urlService.utils.redirect301(res, urlService.getUrlByResourceId(post.id));
+ }
+
+ helpers.secure(req, post);
+
+ filters.doFilter('prePostsRender', post, res.locals)
+ .then(helpers.renderEntry(req, res));
+ })
+ .catch(helpers.handleError(next));
+};
diff --git a/core/server/services/routing/controllers/rss.js b/core/server/services/routing/controllers/rss.js
new file mode 100644
index 0000000000..0ea01cb2e4
--- /dev/null
+++ b/core/server/services/routing/controllers/rss.js
@@ -0,0 +1,57 @@
+const _ = require('lodash'),
+ debug = require('ghost-ignition').debug('services:routing:controllers:rss'),
+ url = require('url'),
+ common = require('../../../lib/common'),
+ security = require('../../../lib/security'),
+ settingsCache = require('../../settings/cache'),
+ rssService = require('../../rss'),
+ helpers = require('../helpers');
+
+// @TODO: is this the right logic? move to UrlService utils
+function getBaseUrlForRSSReq(originalUrl, pageParam) {
+ return url.parse(originalUrl).pathname.replace(new RegExp('/' + pageParam + '/$'), '/');
+}
+
+// @TODO: is this really correct? Should we be using meta data title?
+function getTitle(relatedData) {
+ relatedData = relatedData || {};
+ var titleStart = _.get(relatedData, 'author[0].name') || _.get(relatedData, 'tag[0].name') || '';
+
+ titleStart += titleStart ? ' - ' : '';
+ return titleStart + settingsCache.get('title');
+}
+
+// @TODO: the collection controller does almost the same
+module.exports = function rssController(req, res, next) {
+ debug('rssController');
+
+ const pathOptions = {
+ page: req.params.page !== undefined ? req.params.page : 1,
+ slug: req.params.slug ? security.string.safe(req.params.slug) : undefined
+ };
+
+ // CASE: we are using an rss cache - url must be normalised (without pagination)
+ // @TODO: this belongs to the rss service
+ const baseUrl = getBaseUrlForRSSReq(req.originalUrl, pathOptions.page);
+
+ helpers.fetchData(pathOptions, res.locals.routerOptions)
+ .then(function formatResult(result) {
+ const response = _.pick(result, ['posts', 'meta']);
+
+ response.title = getTitle(result.data);
+ response.description = settingsCache.get('description');
+
+ return response;
+ })
+ .then(function (data) {
+ // CASE: if requested page is greater than number of pages we have
+ if (pathOptions.page > data.meta.pagination.pages) {
+ return next(new common.errors.NotFoundError({
+ message: common.i18n.t('errors.errors.pageNotFound')
+ }));
+ }
+
+ return rssService.render(res, baseUrl, data);
+ })
+ .catch(helpers.handleError(next));
+};
diff --git a/core/server/controllers/frontend/context.js b/core/server/services/routing/helpers/context.js
similarity index 93%
rename from core/server/controllers/frontend/context.js
rename to core/server/services/routing/helpers/context.js
index f5c13cf1c5..888254c91c 100644
--- a/core/server/controllers/frontend/context.js
+++ b/core/server/services/routing/helpers/context.js
@@ -10,10 +10,8 @@
* 2. req.params.page - always has the page parameter, regardless of if the URL contains a keyword (RSS pages don't)
* 3. data - used for telling the difference between posts and pages
*/
-
-var labs = require('../../services/labs'),
-
- // Context patterns, should eventually come from Channel configuration
+const labs = require('../../labs'),
+ // @TODO: fix this, this is app specific and should be dynamic
// routeKeywords.private: 'private'
privatePattern = new RegExp('^\\/private\\/'),
// routeKeywords.subscribe: 'subscribe'
@@ -49,8 +47,8 @@ function setResponseContext(req, res, data) {
}
// Each page can only have at most one of these
- if (res.locals.channel) {
- res.locals.context = res.locals.context.concat(res.locals.channel.context);
+ if (res.locals.routerOptions) {
+ res.locals.context = res.locals.context.concat(res.locals.routerOptions.context);
} else if (privatePattern.test(res.locals.relativeUrl)) {
res.locals.context.push('private');
} else if (subscribePattern.test(res.locals.relativeUrl) && labs.isSet('subscribers') === true) {
diff --git a/core/server/controllers/frontend/error.js b/core/server/services/routing/helpers/error.js
similarity index 100%
rename from core/server/controllers/frontend/error.js
rename to core/server/services/routing/helpers/error.js
diff --git a/core/server/services/routing/helpers/fetch-data.js b/core/server/services/routing/helpers/fetch-data.js
new file mode 100644
index 0000000000..9c15e8caee
--- /dev/null
+++ b/core/server/services/routing/helpers/fetch-data.js
@@ -0,0 +1,120 @@
+/**
+ * # Fetch Data
+ * Dynamically build and execute queries on the API
+ */
+const _ = require('lodash'),
+ Promise = require('bluebird'),
+ urlService = require('../../url'),
+ api = require('../../../api'),
+ defaultPostQuery = {};
+
+// The default settings for a default post query
+const queryDefaults = {
+ type: 'browse',
+ resource: 'posts',
+ options: {}
+};
+
+/**
+ * Default post query needs to always include author, authors & tags
+ *
+ * @deprecated: `author`, will be removed in Ghost 2.0
+ */
+_.extend(defaultPostQuery, queryDefaults, {
+ options: {
+ include: 'author,authors,tags'
+ }
+});
+
+/**
+ * ## Process Query
+ * Takes a 'query' object, ensures that type, resource and options are set
+ * Replaces occurrences of `%s` in options with slugParam
+ * Converts the query config to a promise for the result
+ *
+ * @param {{type: String, resource: String, options: Object}} query
+ * @param {String} slugParam
+ * @returns {Promise} promise for an API call
+ */
+function processQuery(query, slugParam) {
+ query = _.cloneDeep(query);
+
+ // Ensure that all the properties are filled out
+ _.defaultsDeep(query, queryDefaults);
+
+ // Replace any slugs, see TaxonomyRouter. We replace any '%s' by the slug
+ _.each(query.options, function (option, name) {
+ query.options[name] = _.isString(option) ? option.replace(/%s/g, slugParam) : option;
+ });
+
+ // Return a promise for the api query
+ return api[query.resource][query.type](query.options);
+}
+
+/**
+ * ## Fetch Data
+ * Calls out to get posts per page, builds the final posts query & builds any additional queries
+ * Wraps the queries using Promise.props to ensure it gets named responses
+ * Does a first round of formatting on the response, and returns
+ */
+function fetchData(pathOptions, routerOptions) {
+ pathOptions = pathOptions || {};
+ routerOptions = routerOptions || {};
+
+ let postQuery = _.cloneDeep(defaultPostQuery),
+ props = {};
+
+ if (routerOptions.filter) {
+ postQuery.options.filter = routerOptions.filter;
+ }
+
+ if (pathOptions.hasOwnProperty('page')) {
+ postQuery.options.page = pathOptions.page;
+ }
+
+ if (pathOptions.hasOwnProperty('limit')) {
+ postQuery.options.limit = pathOptions.limit;
+ }
+
+ // CASE: always fetch post collection
+ // The filter can in theory contain a "%s" e.g. filter="primary_tag:%s"
+ props.posts = processQuery(postQuery, pathOptions.slug);
+
+ // CASE: fetch more data defined by the router e.g. tags, authors - see TaxonomyRouter
+ _.each(routerOptions.data, function (query, name) {
+ props[name] = processQuery(query, pathOptions.slug);
+ });
+
+ return Promise.props(props)
+ .then(function formatResponse(results) {
+ const response = _.cloneDeep(results.posts);
+ delete results.posts;
+
+ // CASE: does this post belong to this collection?
+ // EXCEPTION: Taxonomies always list the posts which belong to a tag/author.
+ if (!routerOptions.data && routerOptions.identifier) {
+ response.posts = _.filter(response.posts, (post) => {
+ if (urlService.owns(routerOptions.identifier, post.url)) {
+ return post;
+ }
+ });
+ }
+
+ // process any remaining data
+ if (!_.isEmpty(results)) {
+ response.data = {};
+
+ _.each(results, function (result, name) {
+ if (routerOptions.data[name].type === 'browse') {
+ response.data[name] = result;
+ } else {
+ response.data[name] = result[routerOptions.data[name].resource];
+ }
+ });
+ }
+
+ return response;
+ });
+}
+
+module.exports = fetchData;
diff --git a/core/server/controllers/frontend/format-response.js b/core/server/services/routing/helpers/format-response.js
similarity index 93%
rename from core/server/controllers/frontend/format-response.js
rename to core/server/services/routing/helpers/format-response.js
index a4bfd14e92..bf4499c6d6 100644
--- a/core/server/controllers/frontend/format-response.js
+++ b/core/server/services/routing/helpers/format-response.js
@@ -1,4 +1,4 @@
-var _ = require('lodash');
+const _ = require('lodash');
/**
* formats variables for handlebars in multi-post contexts.
@@ -37,6 +37,6 @@ function formatResponse(post) {
}
module.exports = {
- channel: formatPageResponse,
+ collection: formatPageResponse,
entry: formatResponse
};
diff --git a/core/server/services/routing/helpers/index.js b/core/server/services/routing/helpers/index.js
new file mode 100644
index 0000000000..3c18da4295
--- /dev/null
+++ b/core/server/services/routing/helpers/index.js
@@ -0,0 +1,41 @@
+module.exports = {
+ get postLookup() {
+ return require('./post-lookup');
+ },
+
+ get fetchData() {
+ return require('./fetch-data');
+ },
+
+ get renderCollection() {
+ return require('./render-collection');
+ },
+
+ get formatResponse() {
+ return require('./format-response');
+ },
+
+ get renderEntry() {
+ return require('./render-entry');
+ },
+
+ get renderer() {
+ return require('./renderer');
+ },
+
+ get templates() {
+ return require('./templates');
+ },
+
+ get secure() {
+ return require('./secure');
+ },
+
+ get handleError() {
+ return require('./error');
+ },
+
+ get context() {
+ return require('./context');
+ }
+};
diff --git a/core/server/services/routing/helpers/post-lookup.js b/core/server/services/routing/helpers/post-lookup.js
new file mode 100644
index 0000000000..2439db3389
--- /dev/null
+++ b/core/server/services/routing/helpers/post-lookup.js
@@ -0,0 +1,54 @@
+const _ = require('lodash'),
+ Promise = require('bluebird'),
+ url = require('url'),
+ debug = require('ghost-ignition').debug('services:routing:helpers:post-lookup'),
+ routeMatch = require('path-match')(),
+ api = require('../../../api');
+
+function postLookup(postUrl, routerOptions) {
+ debug(postUrl);
+
+ const targetPath = url.parse(postUrl).path,
+ permalinks = routerOptions.permalinks;
+
+ let isEditURL = false;
+
+ // CASE: e.g. /:slug/ -> { slug: 'value' }
+ const matchFunc = routeMatch(permalinks);
+ const params = matchFunc(targetPath);
+
+ debug(params);
+
+ // CASE 1: no matches, resolve
+ // CASE 2: params can be empty e.g. permalink is /featured/:options(edit)?/ and path is /featured/
+ if (params === false || !Object.keys(params).length) {
+ return Promise.resolve();
+ }
+
+ // CASE: redirect if url contains `/edit/` at the end
+ if (params.options && params.options.toLowerCase() === 'edit') {
+ isEditURL = true;
+ }
+
+ /**
+ * Query database to find post.
+ *
+ * @deprecated: `author`, will be removed in Ghost 2.0
+ */
+ return api.posts.read(_.extend(_.pick(params, 'slug', 'id'), {include: 'author,authors,tags'}))
+ .then(function then(result) {
+ const post = result.posts[0];
+
+ if (!post) {
+ return Promise.resolve();
+ }
+
+ return {
+ post: post,
+ isEditURL: isEditURL,
+ isUnknownOption: isEditURL ? false : !!params.options
+ };
+ });
+}
+
+module.exports = postLookup;
diff --git a/core/server/services/routing/helpers/render-collection.js b/core/server/services/routing/helpers/render-collection.js
new file mode 100644
index 0000000000..145be42105
--- /dev/null
+++ b/core/server/services/routing/helpers/render-collection.js
@@ -0,0 +1,12 @@
+const debug = require('ghost-ignition').debug('services:routing:helpers:render-collection'),
+ formatResponse = require('./format-response'),
+ renderer = require('./renderer');
+
+module.exports = function renderCollection(req, res) {
+ debug('renderCollection called');
+ return function renderCollection(result) {
+ // Format data 2
+ // Render
+ return renderer(req, res, formatResponse.collection(result));
+ };
+};
diff --git a/core/server/controllers/frontend/render-entry.js b/core/server/services/routing/helpers/render-entry.js
similarity index 70%
rename from core/server/controllers/frontend/render-entry.js
rename to core/server/services/routing/helpers/render-entry.js
index d25041afaa..8b1f4b22d5 100644
--- a/core/server/controllers/frontend/render-entry.js
+++ b/core/server/services/routing/helpers/render-entry.js
@@ -1,4 +1,4 @@
-var debug = require('ghost-ignition').debug('channels:render-post'),
+const debug = require('ghost-ignition').debug('services:routing:helpers:render-post'),
formatResponse = require('./format-response'),
renderer = require('./renderer');
/*
@@ -10,11 +10,8 @@ var debug = require('ghost-ignition').debug('channels:render-post'),
module.exports = function renderEntry(req, res) {
debug('renderEntry called');
return function renderEntry(entry) {
- // Renderer begin
// Format data 2 - 1 is in preview/entry
- var data = formatResponse.entry(entry);
-
- // Render Call
- return renderer(req, res, data);
+ // Render
+ return renderer(req, res, formatResponse.entry(entry));
};
};
diff --git a/core/server/controllers/frontend/renderer.js b/core/server/services/routing/helpers/renderer.js
similarity index 83%
rename from core/server/controllers/frontend/renderer.js
rename to core/server/services/routing/helpers/renderer.js
index 4204f9be0d..18f4f23ec5 100644
--- a/core/server/controllers/frontend/renderer.js
+++ b/core/server/services/routing/helpers/renderer.js
@@ -1,4 +1,4 @@
-var debug = require('ghost-ignition').debug('renderer'),
+const debug = require('ghost-ignition').debug('services:routing:helpers:renderer'),
setContext = require('./context'),
templates = require('./templates');
@@ -12,5 +12,6 @@ module.exports = function renderer(req, res, data) {
// Render Call
debug('Rendering template: ' + res._template + ' for: ' + req.originalUrl);
debug('res.locals', res.locals);
+
res.render(res._template, data);
};
diff --git a/core/server/controllers/frontend/secure.js b/core/server/services/routing/helpers/secure.js
similarity index 100%
rename from core/server/controllers/frontend/secure.js
rename to core/server/services/routing/helpers/secure.js
diff --git a/core/server/controllers/frontend/templates.js b/core/server/services/routing/helpers/templates.js
similarity index 59%
rename from core/server/controllers/frontend/templates.js
rename to core/server/services/routing/helpers/templates.js
index 981572341d..387e5d08a4 100644
--- a/core/server/controllers/frontend/templates.js
+++ b/core/server/services/routing/helpers/templates.js
@@ -2,11 +2,12 @@
//
// Figure out which template should be used to render a request
// based on the templates which are allowed, and what is available in the theme
-// TODO: consider where this should live as it deals with channels, singles, and errors
-var _ = require('lodash'),
+// TODO: consider where this should live as it deals with collections, entries, and errors
+const _ = require('lodash'),
path = require('path'),
- config = require('../../config'),
- themes = require('../../services/themes'),
+ url = require('url'),
+ config = require('../../../config'),
+ themes = require('../../themes'),
_private = {};
/**
@@ -20,7 +21,7 @@ var _ = require('lodash'),
* @returns {String[]}
*/
_private.getErrorTemplateHierarchy = function getErrorTemplateHierarchy(statusCode) {
- var errorCode = _.toString(statusCode),
+ const errorCode = _.toString(statusCode),
templateList = ['error'];
// Add error class template: E.g. error-4xx.hbs or error-5xx.hbs
@@ -33,30 +34,38 @@ _private.getErrorTemplateHierarchy = function getErrorTemplateHierarchy(statusCo
};
/**
- * ## Get Channel Template Hierarchy
+ * ## Get Collection Template Hierarchy
*
* Fetch the ordered list of templates that can be used to render this request.
* 'index' is the default / fallback
- * For channels with slugs: [:channelName-:slug, :channelName, index]
- * For channels without slugs: [:channelName, index]
- * Channels can also have a front page template which is used if this is the first page of the channel, e.g. 'home.hbs'
+ * For collections with slugs: [:collectionName-:slug, :collectionName, index]
+ * For collections without slugs: [:collectionName, index]
+ * Collections can also have a front page template which is used if this is the first page of the collections, e.g. 'home.hbs'
*
- * @param {Object} channelOpts
+ * @param {Object} routerOptions
* @returns {String[]}
*/
-_private.getChannelTemplateHierarchy = function getChannelTemplateHierarchy(channelOpts) {
- var templateList = ['index'];
+_private.getCollectionTemplateHierarchy = function getCollectionTemplateHierarchy(routerOptions, requestOptions) {
+ const templateList = ['index'];
- if (channelOpts.name && channelOpts.name !== 'index') {
- templateList.unshift(channelOpts.name);
+ // CASE: author, tag
+ if (routerOptions.name && routerOptions.name !== 'index') {
+ templateList.unshift(routerOptions.name);
- if (channelOpts.slugTemplate && channelOpts.slugParam) {
- templateList.unshift(channelOpts.name + '-' + channelOpts.slugParam);
+ if (routerOptions.slugTemplate && routerOptions.slugParam) {
+ templateList.unshift(routerOptions.name + '-' + routerOptions.slugParam);
}
}
- if (channelOpts.frontPageTemplate && channelOpts.postOptions.page === 1) {
- templateList.unshift(channelOpts.frontPageTemplate);
+ // CASE: collections can define a template list
+ if (routerOptions.templates && routerOptions.templates.length) {
+ routerOptions.templates.forEach((template) => {
+ templateList.unshift(template);
+ });
+ }
+
+ if (routerOptions.frontPageTemplate && (requestOptions.path === '/' || requestOptions.path === '/' && requestOptions.page === 1)) {
+ templateList.unshift(routerOptions.frontPageTemplate);
}
return templateList;
@@ -74,8 +83,8 @@ _private.getChannelTemplateHierarchy = function getChannelTemplateHierarchy(chan
* @returns {String[]}
*/
_private.getEntryTemplateHierarchy = function getEntryTemplateHierarchy(postObject) {
- var templateList = ['post'],
- slugTemplate = 'post-' + postObject.slug;
+ const templateList = ['post'];
+ let slugTemplate = 'post-' + postObject.slug;
if (postObject.page) {
templateList.unshift('page');
@@ -101,7 +110,7 @@ _private.getEntryTemplateHierarchy = function getEntryTemplateHierarchy(postObje
* @param {String} fallback - a fallback template
*/
_private.pickTemplate = function pickTemplate(templateList, fallback) {
- var template;
+ let template;
if (!_.isArray(templateList)) {
templateList = [templateList];
@@ -111,37 +120,45 @@ _private.pickTemplate = function pickTemplate(templateList, fallback) {
template = fallback;
} else {
template = _.find(templateList, function (template) {
+ if (!template) {
+ return;
+ }
+
return themes.getActive().hasTemplate(template);
});
}
if (!template) {
- template = fallback;
+ if (!fallback) {
+ template = 'index';
+ } else {
+ template = fallback;
+ }
}
return template;
};
_private.getTemplateForEntry = function getTemplateForEntry(postObject) {
- var templateList = _private.getEntryTemplateHierarchy(postObject),
+ const templateList = _private.getEntryTemplateHierarchy(postObject),
fallback = templateList[templateList.length - 1];
return _private.pickTemplate(templateList, fallback);
};
-_private.getTemplateForChannel = function getTemplateForChannel(channelOpts) {
- var templateList = _private.getChannelTemplateHierarchy(channelOpts),
+_private.getTemplateForCollection = function getTemplateForCollection(routerOptions, requestOptions) {
+ const templateList = _private.getCollectionTemplateHierarchy(routerOptions, requestOptions),
fallback = templateList[templateList.length - 1];
return _private.pickTemplate(templateList, fallback);
};
_private.getTemplateForError = function getTemplateForError(statusCode) {
- var templateList = _private.getErrorTemplateHierarchy(statusCode),
+ const templateList = _private.getErrorTemplateHierarchy(statusCode),
fallback = path.resolve(config.get('paths').defaultViews, 'error.hbs');
return _private.pickTemplate(templateList, fallback);
};
module.exports.setTemplate = function setTemplate(req, res, data) {
- var routeConfig = res._route || {};
+ const routeConfig = res._route || {};
if (res._template && !req.err) {
return;
@@ -156,8 +173,11 @@ module.exports.setTemplate = function setTemplate(req, res, data) {
case 'custom':
res._template = _private.pickTemplate(routeConfig.templateName, routeConfig.defaultTemplate);
break;
- case 'channel':
- res._template = _private.getTemplateForChannel(res.locals.channel);
+ case 'collection':
+ res._template = _private.getTemplateForCollection(res.locals.routerOptions, {
+ path: url.parse(req.url).pathname,
+ page: req.params.page
+ });
break;
case 'entry':
res._template = _private.getTemplateForEntry(data.post);
diff --git a/core/server/services/routing/index.js b/core/server/services/routing/index.js
new file mode 100644
index 0000000000..2a480bc74a
--- /dev/null
+++ b/core/server/services/routing/index.js
@@ -0,0 +1,21 @@
+module.exports = {
+ get bootstrap() {
+ return require('./bootstrap');
+ },
+
+ get registry() {
+ return require('./registry');
+ },
+
+ get helpers() {
+ return require('./helpers');
+ },
+
+ get CollectionRouter() {
+ return require('./CollectionRouter');
+ },
+
+ get TaxonomyRouter() {
+ return require('./TaxonomyRouter');
+ }
+};
diff --git a/core/server/services/routing/middlewares/index.js b/core/server/services/routing/middlewares/index.js
new file mode 100644
index 0000000000..5736fe1ab7
--- /dev/null
+++ b/core/server/services/routing/middlewares/index.js
@@ -0,0 +1,5 @@
+module.exports = {
+ get pageParam() {
+ return require('./page-param');
+ }
+};
diff --git a/core/server/services/routing/middlewares/page-param.js b/core/server/services/routing/middlewares/page-param.js
new file mode 100644
index 0000000000..74db248661
--- /dev/null
+++ b/core/server/services/routing/middlewares/page-param.js
@@ -0,0 +1,27 @@
+const common = require('../../../lib/common/index'),
+ urlService = require('../../url/index');
+
+module.exports = function handlePageParam(req, res, next, page) {
+ // routeKeywords.page: 'page'
+ const pageRegex = new RegExp('/page/(.*)?/'),
+ rssRegex = new RegExp('/rss/(.*)?/');
+
+ page = parseInt(page, 10);
+
+ if (page === 1) {
+ // CASE: page 1 is an alias for the collection index, do a permanent 301 redirect
+ // @TODO: this belongs into the rss router!
+ if (rssRegex.test(req.url)) {
+ return urlService.utils.redirect301(res, req.originalUrl.replace(rssRegex, '/rss/'));
+ } else {
+ return urlService.utils.redirect301(res, req.originalUrl.replace(pageRegex, '/'));
+ }
+ } else if (page < 1 || isNaN(page)) {
+ return next(new common.errors.NotFoundError({
+ message: common.i18n.t('errors.errors.pageNotFound')
+ }));
+ } else {
+ req.params.page = page;
+ return next();
+ }
+};
diff --git a/core/server/services/routing/registry.js b/core/server/services/routing/registry.js
new file mode 100644
index 0000000000..495e608723
--- /dev/null
+++ b/core/server/services/routing/registry.js
@@ -0,0 +1,43 @@
+const _ = require('lodash');
+let routes = [];
+let routers = {};
+
+module.exports = {
+ setRoute(routerName, route) {
+ routes.push({route: route, from: routerName});
+ },
+
+ setRouter(name, router) {
+ routers[name] = router;
+ },
+
+ getAllRoutes() {
+ return _.cloneDeep(routes);
+ },
+
+ getRouter(name) {
+ return routers[name];
+ },
+
+ getFirstCollectionRouter() {
+ return _.find(routers, (router) => {
+ if (router.name === 'CollectionRouter') {
+ return router;
+ }
+
+ return false;
+ });
+ },
+
+ resetAllRoutes() {
+ routes = [];
+ },
+
+ resetAllRouters() {
+ _.each(routers, (value) => {
+ value.reset();
+ });
+
+ routers = {};
+ }
+};
diff --git a/core/server/services/rss/generate-feed.js b/core/server/services/rss/generate-feed.js
index 63779b5591..06896c8aae 100644
--- a/core/server/services/rss/generate-feed.js
+++ b/core/server/services/rss/generate-feed.js
@@ -20,7 +20,7 @@ generateTags = function generateTags(data) {
};
generateItem = function generateItem(post, siteUrl, secure) {
- var itemUrl = urlService.utils.urlFor('post', {post: post, secure: secure}, true),
+ var itemUrl = urlService.getUrlByResourceId(post.id, {secure: secure, absolute: true}),
htmlContent = urlService.utils.makeAbsoluteUrls(post.html, siteUrl, itemUrl),
item = {
title: post.title,
@@ -65,7 +65,7 @@ generateItem = function generateItem(post, siteUrl, secure) {
/**
* Generate Feed
*
- * Data is an object which contains the res.locals + results from fetching a channel, but without related data.
+ * Data is an object which contains the res.locals + results from fetching a collection, but without related data.
*
* @param {string} baseUrl
* @param {{title, description, safeVersion, secure, posts}} data
diff --git a/core/server/services/settings/default-routes.yaml b/core/server/services/settings/default-routes.yaml
index 7979e89ba0..7b53c6542f 100644
--- a/core/server/services/settings/default-routes.yaml
+++ b/core/server/services/settings/default-routes.yaml
@@ -2,7 +2,7 @@ routes:
collections:
/:
- route: '{globals.permalinks}'
+ permalink: '{globals.permalinks}' # special 1.0 compatibility setting. See the docs for details.
template:
- home
- index
diff --git a/core/server/services/settings/index.js b/core/server/services/settings/index.js
index 49241f2c0e..8f5429af6b 100644
--- a/core/server/services/settings/index.js
+++ b/core/server/services/settings/index.js
@@ -6,7 +6,7 @@ const _ = require('lodash'),
SettingsModel = require('../../models/settings').Settings,
SettingsCache = require('./cache'),
SettingsLoader = require('./loader'),
- // EnsureSettingsFiles = require('./ensure-settings'),
+ ensureSettingsFiles = require('./ensure-settings'),
common = require('../../lib/common'),
debug = require('ghost-ignition').debug('services:settings:index');
@@ -16,23 +16,18 @@ module.exports = {
debug('init settings service for:', knownSettings);
- // TODO: uncomment this section, once we want to
- // copy the default routes.yaml file into the /content/settings
- // folder
-
// Make sure that supported settings files are available
// inside of the `content/setting` directory
- // return EnsureSettingsFiles(knownSettings)
- // .then(() => {
-
- // Update the defaults
- return SettingsModel.populateDefaults()
- .then(function (settingsCollection) {
+ return ensureSettingsFiles(knownSettings)
+ .then(() => {
+ // Update the defaults
+ return SettingsModel.populateDefaults();
+ })
+ .then((settingsCollection) => {
// Initialise the cache with the result
// This will bind to events for further updates
SettingsCache.init(settingsCollection);
});
- // });
},
/**
diff --git a/core/server/services/settings/loader.js b/core/server/services/settings/loader.js
index db16703481..40c64f6925 100644
--- a/core/server/services/settings/loader.js
+++ b/core/server/services/settings/loader.js
@@ -3,7 +3,8 @@ const fs = require('fs-extra'),
debug = require('ghost-ignition').debug('services:settings:settings-loader'),
common = require('../../lib/common'),
config = require('../../config'),
- yamlParser = require('./yaml-parser');
+ yamlParser = require('./yaml-parser'),
+ validate = require('./validate');
/**
* Reads the desired settings YAML file and passes the
@@ -21,8 +22,8 @@ module.exports = function loadSettings(setting) {
const file = fs.readFileSync(filePath, 'utf8');
debug('settings file found for', setting);
- // yamlParser returns a JSON object
- return yamlParser(file, fileName);
+ const object = yamlParser(file, fileName);
+ return validate(object);
} catch (err) {
if (common.errors.utils.isIgnitionError(err)) {
throw err;
diff --git a/core/server/services/settings/validate.js b/core/server/services/settings/validate.js
new file mode 100644
index 0000000000..26d04ea249
--- /dev/null
+++ b/core/server/services/settings/validate.js
@@ -0,0 +1,205 @@
+const _ = require('lodash');
+const common = require('../../lib/common');
+const _private = {};
+
+_private.validateRoutes = function validateRoutes(routes) {
+ _.each(routes, (routingTypeObject, routingTypeObjectKey) => {
+ // CASE: we hard-require trailing slashes for the index route
+ if (!routingTypeObjectKey.match(/\/$/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'A trailing slash is required.'
+ })
+ });
+ }
+
+ // CASE: we hard-require leading slashes for the index route
+ if (!routingTypeObjectKey.match(/^\//)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'A leading slash is required.'
+ })
+ });
+ }
+
+ // CASE: you define /about/:
+ if (!routingTypeObject) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'Please define a permalink route.'
+ }),
+ help: 'e.g. permalink: /{slug}/'
+ });
+ }
+ });
+
+ return routes;
+};
+
+_private.validateCollections = function validateCollections(collections) {
+ _.each(collections, (routingTypeObject, routingTypeObjectKey) => {
+ // CASE: we hard-require trailing slashes for the collection index route
+ if (!routingTypeObjectKey.match(/\/$/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'A trailing slash is required.'
+ })
+ });
+ }
+
+ // CASE: we hard-require leading slashes for the collection index route
+ if (!routingTypeObjectKey.match(/^\//)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'A leading slash is required.'
+ })
+ });
+ }
+
+ if (!routingTypeObject.hasOwnProperty('permalink')) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'Please define a permalink route.'
+ }),
+ help: 'e.g. permalink: /{slug}/'
+ });
+ }
+
+ // CASE: validate permalink key
+ if (routingTypeObject.hasOwnProperty('permalink')) {
+ if (!routingTypeObject.permalink) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'Please define a permalink route.'
+ }),
+ help: 'e.g. permalink: /{slug}/'
+ });
+ }
+
+ // CASE: we hard-require trailing slashes for the value/permalink route
+ if (!routingTypeObject.permalink.match(/\/$/) && !routingTypeObject.permalink.match(/globals\.permalinks/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObject.permalink,
+ reason: 'A trailing slash is required.'
+ })
+ });
+ }
+
+ // CASE: we hard-require leading slashes for the value/permalink route
+ if (!routingTypeObject.permalink.match(/^\//) && !routingTypeObject.permalink.match(/globals\.permalinks/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObject.permalink,
+ reason: 'A leading slash is required.'
+ })
+ });
+ }
+
+ // CASE: notation /:slug/ or /:primary_author/ is not allowed. We only accept /{{...}}/.
+ if (routingTypeObject.permalink && routingTypeObject.permalink.match(/\/\:\w+/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObject.permalink,
+ reason: 'Please use the following notation e.g. /{slug}/.'
+ })
+ });
+ }
+
+ // CASE: transform {.*} into :\w+ notation. This notation is our internal notation e.g. see permalink
+ // replacement in our UrlService utility.
+ if (routingTypeObject.permalink.match(/{.*}/)) {
+ routingTypeObject.permalink = routingTypeObject.permalink.replace(/{(\w+)}/g, ':$1');
+ }
+ }
+ });
+
+ return collections;
+};
+
+_private.validateTaxonomies = function validateTaxonomies(taxonomies) {
+ _.each(taxonomies, (routingTypeObject, routingTypeObjectKey) => {
+ if (!routingTypeObject) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObjectKey,
+ reason: 'Please define a taxonomy permalink route.'
+ }),
+ help: 'e.g. tag: /tag/{slug}/'
+ });
+ }
+
+ // CASE: we hard-require trailing slashes for the taxonomie permalink route
+ if (!routingTypeObject.match(/\/$/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObject,
+ reason: 'A trailing slash is required.'
+ })
+ });
+ }
+
+ // CASE: we hard-require leading slashes for the value/permalink route
+ if (!routingTypeObject.match(/^\//)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObject,
+ reason: 'A leading slash is required.'
+ })
+ });
+ }
+
+ // CASE: notation /:slug/ or /:primary_author/ is not allowed. We only accept /{{...}}/.
+ if (routingTypeObject && routingTypeObject.match(/\/\:\w+/)) {
+ throw new common.errors.ValidationError({
+ message: common.i18n.t('errors.services.settings.yaml.validate', {
+ at: routingTypeObject,
+ reason: 'Please use the following notation e.g. /{slug}/.'
+ })
+ });
+ }
+
+ // CASE: transform {.*} into :\w+ notation. This notation is our internal notation e.g. see permalink
+ // replacement in our UrlService utility.
+ if (routingTypeObject && routingTypeObject.match(/{.*}/)) {
+ routingTypeObject = routingTypeObject.replace(/{(\w+)}/g, ':$1');
+ taxonomies[routingTypeObjectKey] = routingTypeObject;
+ }
+ });
+
+ return taxonomies;
+};
+
+/**
+ * Validate and sanitize the routing object.
+ */
+module.exports = function validate(object) {
+ if (!object) {
+ object = {};
+ }
+
+ if (!object.routes) {
+ object.routes = {};
+ }
+
+ if (!object.collections) {
+ object.collections = {};
+ }
+
+ if (!object.taxonomies) {
+ object.taxonomies = {};
+ }
+
+ object.routes = _private.validateRoutes(object.routes);
+ object.collections = _private.validateCollections(object.collections);
+ object.taxonomies = _private.validateTaxonomies(object.taxonomies);
+
+ return object;
+};
diff --git a/core/server/services/slack.js b/core/server/services/slack.js
index d669694ee4..e91b47b43d 100644
--- a/core/server/services/slack.js
+++ b/core/server/services/slack.js
@@ -28,7 +28,7 @@ function ping(post) {
// If this is a post, we want to send the link of the post
if (schema.isPost(post)) {
- message = urlService.utils.urlFor('post', {post: post}, true);
+ message = urlService.getUrlByResourceId(post.id, {absolute: true});
} else {
message = post.message;
}
diff --git a/core/server/services/url/Queue.js b/core/server/services/url/Queue.js
index a17b33008c..c8bb70407f 100644
--- a/core/server/services/url/Queue.js
+++ b/core/server/services/url/Queue.js
@@ -9,7 +9,7 @@ const debug = require('ghost-ignition').debug('services:url:queue'),
* Ghost fetches as earliest as possible the resources from the database. The reason is simply: we
* want to know all urls as soon as possible.
*
- * Parallel to this, the routes/channels are read/prepared and registered in express.
+ * Parallel to this, the routes are read/prepared and registered in express.
* So the challenge is to handle both resource availability and route registration.
* If you start an event, all subscribers of it are executed in a sequence. The queue will wait
* till the current subscriber has finished it's work.
@@ -21,9 +21,9 @@ const debug = require('ghost-ignition').debug('services:url:queue'),
*
* - you can re-run an event
* - you can add more subscribers to an existing queue
- * - you can order subscribers (helpful if you want to order routes/channels)
+ * - you can order subscribers (helpful if you want to order routers)
*
- * Each subscriber represents one instance of the url generator. One url generator represents one channel/route.
+ * Each subscriber represents one instance of the url generator. One url generator represents one router.
*
* ### Tolerance option
*
@@ -201,6 +201,14 @@ class Queue extends EventEmitter {
this.toNotify = {};
}
+
+ softReset() {
+ _.each(this.toNotify, (obj) => {
+ clearTimeout(obj.timeout);
+ });
+
+ this.toNotify = {};
+ }
}
module.exports = Queue;
diff --git a/core/server/services/url/Resources.js b/core/server/services/url/Resources.js
index ff4aeba73c..7ce88c746b 100644
--- a/core/server/services/url/Resources.js
+++ b/core/server/services/url/Resources.js
@@ -246,6 +246,14 @@ class Resources {
this.listeners = [];
this.data = {};
}
+
+ softReset() {
+ this.data = {};
+
+ _.each(resourcesConfig, (resourceConfig) => {
+ this.data[resourceConfig.type] = [];
+ });
+ }
}
module.exports = Resources;
diff --git a/core/server/services/url/UrlGenerator.js b/core/server/services/url/UrlGenerator.js
index 19c3724da3..bfdcac5083 100644
--- a/core/server/services/url/UrlGenerator.js
+++ b/core/server/services/url/UrlGenerator.js
@@ -1,9 +1,7 @@
const _ = require('lodash'),
- moment = require('moment-timezone'),
jsonpath = require('jsonpath'),
debug = require('ghost-ignition').debug('services:url:generator'),
localUtils = require('./utils'),
- settingsCache = require('../settings/cache'),
/**
* @TODO: This is a fake version of the upcoming GQL tool.
* GQL will offer a tool to match a JSON against a filter.
@@ -20,8 +18,8 @@ const _ = require('lodash'),
};
class UrlGenerator {
- constructor(routingType, queue, resources, urls, position) {
- this.routingType = routingType;
+ constructor(router, queue, resources, urls, position) {
+ this.router = router;
this.queue = queue;
this.urls = urls;
this.resources = resources;
@@ -29,9 +27,9 @@ class UrlGenerator {
debug('constructor', this.toString());
- // CASE: channels can define custom filters, but not required.
- if (this.routingType.getFilter()) {
- this.filter = transformFilter(this.routingType.getFilter());
+ // CASE: routers can define custom filters, but not required.
+ if (this.router.getFilter()) {
+ this.filter = transformFilter(this.router.getFilter());
debug('filter', this.filter);
}
@@ -43,7 +41,7 @@ class UrlGenerator {
* @NOTE: currently only used if the permalink setting changes and it's used for this url generator.
* @TODO: remove in Ghost 2.0
*/
- this.routingType.addListener('updated', () => {
+ this.router.addListener('updated', () => {
const myResources = this.urls.getByGeneratorId(this.uid);
myResources.forEach((object) => {
@@ -74,7 +72,7 @@ class UrlGenerator {
debug('_onInit', this.toString());
// @NOTE: get the resources of my type e.g. posts.
- const resources = this.resources.getAllByType(this.routingType.getType());
+ const resources = this.resources.getAllByType(this.router.getType());
_.each(resources, (resource) => {
this._try(resource);
@@ -85,7 +83,7 @@ class UrlGenerator {
debug('onAdded', this.toString());
// CASE: you are type "pages", but the incoming type is "users"
- if (event.type !== this.routingType.getType()) {
+ if (event.type !== this.router.getType()) {
return;
}
@@ -138,57 +136,10 @@ class UrlGenerator {
* We currently generate relative urls.
*/
_generateUrl(resource) {
- let url = this.routingType.getPermalinks().getValue();
- url = this._replacePermalink(url, resource);
+ const permalink = this.router.getPermalinks().getValue();
+ const url = localUtils.replacePermalink(permalink, resource.data);
- return localUtils.createUrl(url, false, false);
- }
-
- /**
- * @TODO:
- * This is a copy of `replacePermalink` of our url utility, see ./utils.
- * But it has modifications, because the whole url utility doesn't work anymore.
- * We will rewrite some of the functions in the utility.
- */
- _replacePermalink(url, resource) {
- var output = url,
- primaryTagFallback = 'all',
- publishedAtMoment = moment.tz(resource.data.published_at || Date.now(), settingsCache.get('active_timezone')),
- permalink = {
- year: function () {
- return publishedAtMoment.format('YYYY');
- },
- month: function () {
- return publishedAtMoment.format('MM');
- },
- day: function () {
- return publishedAtMoment.format('DD');
- },
- author: function () {
- return resource.data.primary_author.slug;
- },
- primary_author: function () {
- return resource.data.primary_author ? resource.data.primary_author.slug : primaryTagFallback;
- },
- primary_tag: function () {
- return resource.data.primary_tag ? resource.data.primary_tag.slug : primaryTagFallback;
- },
- slug: function () {
- return resource.data.slug;
- },
- id: function () {
- return resource.data.id;
- }
- };
-
- // replace tags like :slug or :year with actual values
- output = output.replace(/(:[a-z_]+)/g, function (match) {
- if (_.has(permalink, match.substr(1))) {
- return permalink[match.substr(1)]();
- }
- });
-
- return output;
+ return localUtils.createUrl(url, false, false, true);
}
/**
@@ -218,7 +169,7 @@ class UrlGenerator {
action: 'added:' + resource.data.id,
eventData: {
id: resource.data.id,
- type: this.routingType.getType()
+ type: this.router.getType()
}
});
}
@@ -234,12 +185,22 @@ class UrlGenerator {
resource.addListener('removed', onRemoved.bind(this));
}
+ hasUrl(url) {
+ const existingUrl = this.urls.getByUrl(url);
+
+ if (existingUrl.length && existingUrl[0].generatorId === this.uid) {
+ return true;
+ }
+
+ return false;
+ }
+
getUrls() {
return this.urls.getByGeneratorId(this.uid);
}
toString() {
- return this.routingType.toString();
+ return this.router.toString();
}
}
diff --git a/core/server/services/url/UrlService.js b/core/server/services/url/UrlService.js
index 76d1d24f7e..b8d18f470b 100644
--- a/core/server/services/url/UrlService.js
+++ b/core/server/services/url/UrlService.js
@@ -9,16 +9,9 @@ const _debug = require('ghost-ignition').debug._base,
localUtils = require('./utils');
class UrlService {
- constructor(options) {
- options = options || {};
-
+ constructor() {
this.utils = localUtils;
- // You can disable the url preload, in case we encounter a problem with the new url service.
- if (options.disableUrlPreload) {
- return;
- }
-
this.finished = false;
this.urlGenerators = [];
@@ -31,10 +24,10 @@ class UrlService {
_listeners() {
/**
- * The purpose of this event is to notify the url service as soon as a channel get's created.
+ * The purpose of this event is to notify the url service as soon as a router get's created.
*/
- this._onRoutingTypeListener = this._onRoutingType.bind(this);
- common.events.on('routingType.created', this._onRoutingTypeListener);
+ this._onRouterAddedListener = this._onRouterAddedType.bind(this);
+ common.events.on('router.created', this._onRouterAddedListener);
/**
* The queue will notify us if url generation has started/finished.
@@ -44,9 +37,6 @@ class UrlService {
this._onQueueEndedListener = this._onQueueEnded.bind(this);
this.queue.addListener('ended', this._onQueueEnded.bind(this));
-
- this._resetListener = this.reset.bind(this);
- common.events.on('server.stop', this._resetListener);
}
_onQueueStarted(event) {
@@ -61,21 +51,42 @@ class UrlService {
}
}
- _onRoutingType(routingType) {
- debug('routingType.created');
+ _onRouterAddedType(router) {
+ // CASE: there are router types which do not generate resource urls
+ // e.g. static route router
+ // we are listening on the general `router.created` event - every router throws this event
+ if (!router || !router.getPermalinks()) {
+ return;
+ }
- let urlGenerator = new UrlGenerator(routingType, this.queue, this.resources, this.urls, this.urlGenerators.length);
+ debug('router.created');
+
+ let urlGenerator = new UrlGenerator(router, this.queue, this.resources, this.urls, this.urlGenerators.length);
this.urlGenerators.push(urlGenerator);
}
/**
* You have a url and want to know which the url belongs to.
+ *
* It's in theory possible that multiple resources generate the same url,
- * but they both would serve different content e.g. static pages and collections.
+ * but they both would serve different content.
+ *
+ * e.g. if we remove the slug uniqueness and you create a static
+ * page and a post with the same slug. And both are served under `/` with the permalink `/:slug/`.
+ *
+ *
+ * Each url is unique and it depends on the hierarchy of router registration is configured.
+ * There is no url collision, everything depends on registration order.
+ *
+ * e.g. posts which live in a collection are stronger than a static page.
*
* We only return the resource, which would be served.
+ *
+ * @NOTE: only accepts relative urls at the moment.
*/
- getResource(url) {
+ getResource(url, options) {
+ options = options || {};
+
let objects = this.urls.getByUrl(url);
if (!objects.length) {
@@ -102,9 +113,15 @@ class UrlService {
toReturn.push(object);
}
}
+
+ return toReturn;
}, []);
}
+ if (options.returnEverything) {
+ return objects[0];
+ }
+
return objects[0].resource;
}
@@ -114,18 +131,71 @@ class UrlService {
/**
* Get url by resource id.
+ * e.g. tags, authors, posts, pages
+ *
+ * If we can't find a url for an id, we have to return a url.
+ * There are many components in Ghost which call `getUrlByResourceId` and
+ * based on the return value, they set the resource url somewhere e.g. meta data.
+ * Or if you define no collections in your yaml file and serve a page.
+ * You will see a suggestion of posts, but they all don't belong to a collection.
+ * They would show localhost:2368/null/.
*/
- getUrlByResourceId(id) {
+ getUrlByResourceId(id, options) {
+ options = options || {};
+
const obj = this.urls.getByResourceId(id);
if (obj) {
+ if (options.absolute) {
+ return this.utils.createUrl(obj.url, options.absolute, options.secure);
+ }
+
return obj.url;
}
- return null;
+ if (options.absolute) {
+ return this.utils.createUrl('/404/', options.absolute, options.secure);
+ }
+
+ return '/404/';
+ }
+
+ owns(routerId, url) {
+ debug('owns', routerId, url);
+
+ let urlGenerator;
+
+ this.urlGenerators.every((_urlGenerator) => {
+ if (_urlGenerator.router.identifier === routerId) {
+ urlGenerator = _urlGenerator;
+ return false;
+ }
+
+ return true;
+ });
+
+ if (!urlGenerator) {
+ return false;
+ }
+
+ return urlGenerator.hasUrl(url);
+ }
+
+ getPermalinkByUrl(url, options) {
+ options = options || {};
+
+ const object = this.getResource(url, {returnEverything: true});
+
+ if (!object) {
+ return null;
+ }
+
+ return _.find(this.urlGenerators, {uid: object.generatorId}).router.getPermalinks()
+ .getValue(options);
}
reset() {
+ debug('reset');
this.urlGenerators = [];
this.urls.reset();
@@ -134,8 +204,24 @@ class UrlService {
this._onQueueStartedListener && this.queue.removeListener('started', this._onQueueStartedListener);
this._onQueueEndedListener && this.queue.removeListener('ended', this._onQueueEndedListener);
- this._onRoutingTypeListener && common.events.removeListener('routingType.created', this._onRoutingTypeListener);
- this._resetListener && common.events.removeListener('server.stop', this._resetListener);
+ this._onRouterAddedListener && common.events.removeListener('router.created', this._onRouterAddedListener);
+ }
+
+ resetGenerators() {
+ debug('resetGenerators');
+ this.finished = false;
+ this.urlGenerators = [];
+ this.urls.reset();
+ this.queue.reset();
+ this.resources.softReset();
+ }
+
+ softReset() {
+ debug('softReset');
+ this.finished = false;
+ this.urls.softReset();
+ this.queue.softReset();
+ this.resources.softReset();
}
}
diff --git a/core/server/services/url/Urls.js b/core/server/services/url/Urls.js
index 6fa1a2a805..e57682cd74 100644
--- a/core/server/services/url/Urls.js
+++ b/core/server/services/url/Urls.js
@@ -72,7 +72,8 @@ class Urls {
* - resource1 -> /welcome/
* - resource2 -> /welcome/
*
- * But depending on the routing registration, you will always serve e.g. resource1.
+ * But depending on the routing registration, you will always serve e.g. resource1,
+ * because the router it belongs to was registered first.
*/
getByUrl(url) {
return _.reduce(Object.keys(this.urls), (toReturn, resourceId) => {
@@ -102,6 +103,10 @@ class Urls {
reset() {
this.urls = {};
}
+
+ softReset() {
+ this.urls = {};
+ }
}
module.exports = Urls;
diff --git a/core/server/services/url/index.js b/core/server/services/url/index.js
index 6111f37d5e..cd4b29fe23 100644
--- a/core/server/services/url/index.js
+++ b/core/server/services/url/index.js
@@ -1,8 +1,5 @@
-const config = require('../../config'),
- UrlService = require('./UrlService'),
- urlService = new UrlService({
- disableUrlPreload: config.get('disableUrlPreload')
- });
+const UrlService = require('./UrlService'),
+ urlService = new UrlService();
// Singleton
module.exports = urlService;
diff --git a/core/server/services/url/utils.js b/core/server/services/url/utils.js
index 88a9322976..fe1d05b043 100644
--- a/core/server/services/url/utils.js
+++ b/core/server/services/url/utils.js
@@ -149,7 +149,7 @@ function getAdminUrl() {
// - secure (optional, default:false) - boolean whether or not to force SSL
// Returns:
// - a URL which always ends with a slash
-function createUrl(urlPath, absolute, secure) {
+function createUrl(urlPath, absolute, secure, trailingSlash) {
urlPath = urlPath || '/';
absolute = absolute || false;
var base;
@@ -161,24 +161,23 @@ function createUrl(urlPath, absolute, secure) {
base = getSubdir();
}
+ if (trailingSlash) {
+ if (!urlPath.match(/\/$/)) {
+ urlPath += '/';
+ }
+ }
+
return urlJoin(base, urlPath);
}
/**
* creates the url path for a post based on blog timezone and permalink pattern
- *
- * @param {JSON} post
- * @returns {string}
*/
-function urlPathForPost(post) {
- var output = '',
- permalinks = settingsCache.get('permalinks'),
- // routeKeywords.primaryTagFallback: 'all'
+function replacePermalink(permalink, resource) {
+ let output = permalink,
primaryTagFallback = 'all',
- // routeKeywords.primaryAuthorFallback: 'all'
- primaryAuthorFallback = 'all',
- publishedAtMoment = moment.tz(post.published_at || Date.now(), settingsCache.get('active_timezone')),
- tags = {
+ publishedAtMoment = moment.tz(resource.published_at || Date.now(), settingsCache.get('active_timezone')),
+ permalinkLookUp = {
year: function () {
return publishedAtMoment.format('YYYY');
},
@@ -188,36 +187,27 @@ function urlPathForPost(post) {
day: function () {
return publishedAtMoment.format('DD');
},
- /**
- * @deprecated: `author`, will be removed in Ghost 2.0
- */
author: function () {
- return post.author.slug;
- },
- primary_tag: function () {
- return post.primary_tag ? post.primary_tag.slug : primaryTagFallback;
+ return resource.primary_author.slug;
},
primary_author: function () {
- return post.primary_author ? post.primary_author.slug : primaryAuthorFallback;
+ return resource.primary_author ? resource.primary_author.slug : primaryTagFallback;
+ },
+ primary_tag: function () {
+ return resource.primary_tag ? resource.primary_tag.slug : primaryTagFallback;
},
slug: function () {
- return post.slug;
+ return resource.slug;
},
id: function () {
- return post.id;
+ return resource.id;
}
};
- if (post.page) {
- output += '/:slug/';
- } else {
- output += permalinks;
- }
-
// replace tags like :slug or :year with actual values
output = output.replace(/(:[a-z_]+)/g, function (match) {
- if (_.has(tags, match.substr(1))) {
- return tags[match.substr(1)]();
+ if (_.has(permalinkLookUp, match.substr(1))) {
+ return permalinkLookUp[match.substr(1)]();
}
});
@@ -226,17 +216,13 @@ function urlPathForPost(post) {
// ## urlFor
// Synchronous url creation for a given context
-// Can generate a url for a named path, given path, or known object (post)
+// Can generate a url for a named path and given path.
// Determines what sort of context it has been given, and delegates to the correct generation method,
// Finally passing to createUrl, to ensure any subdirectory is honoured, and the url is absolute if needed
// Usage:
// urlFor('home', true) -> http://my-ghost-blog.com/
// E.g. /blog/ subdir
// urlFor({relativeUrl: '/my-static-page/'}) -> /blog/my-static-page/
-// E.g. if post object represents welcome post, and slugs are set to standard
-// urlFor('post', {...}) -> /welcome-to-ghost/
-// E.g. if post object represents welcome post, and slugs are set to date
-// urlFor('post', {...}) -> /2014/01/01/welcome-to-ghost/
// Parameters:
// - context - a string, or json object describing the context for which you need a url
// - data (optional) - a json object containing data needed to generate a url
@@ -246,13 +232,12 @@ function urlPathForPost(post) {
function urlFor(context, data, absolute) {
var urlPath = '/',
secure, imagePathRe,
- knownObjects = ['post', 'tag', 'author', 'image', 'nav'], baseUrl,
+ knownObjects = ['image', 'nav'], baseUrl,
hostname,
// this will become really big
knownPaths = {
home: '/',
- rss: '/rss/',
api: API_PATH,
sitemap_xsl: '/sitemap.xsl'
};
@@ -269,19 +254,7 @@ function urlFor(context, data, absolute) {
if (_.isObject(context) && context.relativeUrl) {
urlPath = context.relativeUrl;
} else if (_.isString(context) && _.indexOf(knownObjects, context) !== -1) {
- // trying to create a url for an object
- if (context === 'post' && data.post) {
- urlPath = data.post.url;
- secure = data.secure;
- } else if (context === 'tag' && data.tag) {
- // routeKeywords.tag: 'tag'
- urlPath = urlJoin('/tag', data.tag.slug, '/');
- secure = data.tag.secure;
- } else if (context === 'author' && data.author) {
- // routeKeywords.author: 'author'
- urlPath = urlJoin('/author', data.author.slug, '/');
- secure = data.author.secure;
- } else if (context === 'image' && data.image) {
+ if (context === 'image' && data.image) {
urlPath = data.image;
imagePathRe = new RegExp('^' + getSubdir() + '/' + STATIC_IMAGE_URL_PREFIX);
absolute = imagePathRe.test(data.image) ? absolute : false;
@@ -435,16 +408,27 @@ function makeAbsoluteUrls(html, siteUrl, itemUrl) {
return htmlContent;
}
+function absoluteToRelative(urlToModify) {
+ const urlObj = url.parse(urlToModify);
+ return urlObj.pathname;
+}
+
+function deduplicateDoubleSlashes(url) {
+ return url.replace(/\/\//g, '/');
+}
+
+module.exports.absoluteToRelative = absoluteToRelative;
module.exports.makeAbsoluteUrls = makeAbsoluteUrls;
module.exports.getProtectedSlugs = getProtectedSlugs;
module.exports.getSubdir = getSubdir;
module.exports.urlJoin = urlJoin;
module.exports.urlFor = urlFor;
module.exports.isSSL = isSSL;
-module.exports.urlPathForPost = urlPathForPost;
+module.exports.replacePermalink = replacePermalink;
module.exports.redirectToAdmin = redirectToAdmin;
module.exports.redirect301 = redirect301;
module.exports.createUrl = createUrl;
+module.exports.deduplicateDoubleSlashes = deduplicateDoubleSlashes;
/**
* If you request **any** image in Ghost, it get's served via
diff --git a/core/server/services/xmlrpc.js b/core/server/services/xmlrpc.js
index cef399937d..7b53f3597d 100644
--- a/core/server/services/xmlrpc.js
+++ b/core/server/services/xmlrpc.js
@@ -28,7 +28,7 @@ var _ = require('lodash'),
function ping(post) {
var pingXML,
title = post.title,
- url = urlService.utils.urlFor('post', {post: post}, true);
+ url = urlService.getUrlByResourceId(post.id, {absolute: true});
if (post.page || config.isPrivacyDisabled('useRpcPing') || settingsCache.get('is_private')) {
return;
diff --git a/core/server/translations/en.json b/core/server/translations/en.json
index a47520345e..8c0938a303 100644
--- a/core/server/translations/en.json
+++ b/core/server/translations/en.json
@@ -172,6 +172,7 @@
},
"general": {
"maintenance": "Ghost is currently undergoing maintenance, please wait a moment then retry.",
+ "maintenanceUrlService": "Ghost currently generates your blog urls. Please try again.",
"requiredOnFuture": "This will be required in future. Please see {link}",
"internalError": "Something went wrong.",
"jsonParse": "Could not parse JSON: {context}."
@@ -472,7 +473,8 @@
"settings": {
"yaml": {
"error": "Could not parse {file}: {context}.",
- "help": "Check your {file} file for typos and fix the named issues."
+ "help": "Check your {file} file for typos and fix the named issues.",
+ "validate": "The following definition \"{at}\" is invalid: {reason}"
},
"loader": "Error trying to load YAML setting for {setting} from '{path}'.",
"ensureSettings": "Error trying to access settings files in {path}."
diff --git a/core/server/web/middleware/error-handler.js b/core/server/web/middleware/error-handler.js
index 254491d4db..c12bd96ba5 100644
--- a/core/server/web/middleware/error-handler.js
+++ b/core/server/web/middleware/error-handler.js
@@ -2,7 +2,7 @@ var _ = require('lodash'),
hbs = require('express-hbs'),
config = require('../../config'),
common = require('../../lib/common'),
- templates = require('../../controllers/frontend/templates'),
+ helpers = require('../../services/routing/helpers'),
escapeExpression = hbs.Utils.escapeExpression,
_private = {},
errorHandler = {};
@@ -98,7 +98,8 @@ _private.ThemeErrorRenderer = function ThemeErrorRenderer(err, req, res, next) {
};
// Template
- templates.setTemplate(req, res);
+ // @TODO: very dirty !!!!!!
+ helpers.templates.setTemplate(req, res);
// It can be that something went wrong with the theme or otherwise loading handlebars
// This ensures that no matter what res.render will work here
diff --git a/core/server/web/middleware/index.js b/core/server/web/middleware/index.js
new file mode 100644
index 0000000000..4bdeff7ff7
--- /dev/null
+++ b/core/server/web/middleware/index.js
@@ -0,0 +1,5 @@
+module.exports = {
+ get maintenance() {
+ return require('./maintenance');
+ }
+};
diff --git a/core/server/web/middleware/maintenance.js b/core/server/web/middleware/maintenance.js
index 2f3bf74d9e..d5b5dad7dd 100644
--- a/core/server/web/middleware/maintenance.js
+++ b/core/server/web/middleware/maintenance.js
@@ -1,5 +1,6 @@
var config = require('../../config'),
- common = require('../../lib/common');
+ common = require('../../lib/common'),
+ urlService = require('../../services/url');
module.exports = function maintenance(req, res, next) {
if (config.get('maintenance').enabled) {
@@ -8,5 +9,11 @@ module.exports = function maintenance(req, res, next) {
}));
}
+ if (!urlService.hasFinished()) {
+ return next(new common.errors.MaintenanceError({
+ message: common.i18n.t('errors.general.maintenanceUrlService')
+ }));
+ }
+
next();
};
diff --git a/core/server/web/site/app.js b/core/server/web/site/app.js
index aa1244a48f..d0e0e8d548 100644
--- a/core/server/web/site/app.js
+++ b/core/server/web/site/app.js
@@ -123,9 +123,6 @@ module.exports = function setupSiteApp() {
debug('General middleware done');
- // @temporary
- require('../../services/channels/Channels2');
-
// Set up Frontend routes (including private blogging routes)
siteApp.use(siteRoutes());
diff --git a/core/server/web/site/routes.js b/core/server/web/site/routes.js
index d5db57a8ae..8670096200 100644
--- a/core/server/web/site/routes.js
+++ b/core/server/web/site/routes.js
@@ -1,46 +1,5 @@
-var debug = require('ghost-ignition').debug('site:routes'),
-
- routeService = require('../../services/route'),
- siteRouter = routeService.siteRouter,
-
- // Sub Routers
- appRouter = routeService.appRouter,
- channelsService = require('../../services/channels'),
-
- // Controllers
- controllers = require('../../controllers'),
-
- // Utils for creating paths
- // @TODO: refactor these away
- urlService = require('../../services/url');
+const routing = require('../../services/routing');
module.exports = function siteRoutes() {
- // @TODO move this path out of this file!
- // Note this also exists in api/events.js
- // routeKeywords.preview: 'p'
- var previewRoute = urlService.utils.urlJoin('/p', ':uuid', ':options?');
-
- // Preview - register controller as route
- // Ideal version, as we don't want these paths all over the place
- // previewRoute = new Route('GET /:t_preview/:uuid/:options?', previewController);
- // siteRouter.mountRoute(previewRoute);
- // Orrrrr maybe preview should be an internal App??!
- siteRouter.mountRoute(previewRoute, controllers.preview);
-
- // Channels - register sub-router
- // The purpose of having a parentRouter for channels, is so that we can load channels from wherever we want:
- // config, settings, apps, etc, and that it will be possible for the router to be reloaded.
- siteRouter.mountRouter(channelsService.router());
-
- // Apps - register sub-router
- // The purpose of having a parentRouter for apps, is that Apps can register a route whenever they want.
- // Apps cannot yet deregister, it's complex to implement and I don't yet have a clear use-case for this.
- siteRouter.mountRouter(appRouter.router());
-
- // Default - register entry controller as route
- siteRouter.mountRoute('*', controllers.entry);
-
- debug('Routes:', routeService.registry.getAll());
-
- return siteRouter.router();
+ return routing.bootstrap();
};
diff --git a/core/test/functional/routes/channel_spec.js b/core/test/functional/routes/dynamic_routing_spec.js
similarity index 95%
rename from core/test/functional/routes/channel_spec.js
rename to core/test/functional/routes/dynamic_routing_spec.js
index 9f4d11a30e..921bcc949b 100644
--- a/core/test/functional/routes/channel_spec.js
+++ b/core/test/functional/routes/dynamic_routing_spec.js
@@ -1,19 +1,22 @@
-// # Channel Route Tests
+// # Dynamic Routing Tests
// As it stands, these tests depend on the database, and as such are integration tests.
// These tests are here to cover the headers sent with requests and high-level redirects that can't be
// tested with the unit tests
-var should = require('should'),
+const should = require('should'),
supertest = require('supertest'),
sinon = require('sinon'),
+ moment = require('moment'),
testUtils = require('../../utils'),
cheerio = require('cheerio'),
config = require('../../../../core/server/config'),
+ urlService = require('../../../../core/server/services/url'),
settingsCache = require('../../../server/services/settings/cache'),
ghost = testUtils.startGhost,
- sandbox = sinon.sandbox.create(),
- request;
+ sandbox = sinon.sandbox.create();
-describe('Channel Routes', function () {
+let request;
+
+describe('Dynamic Routing', function () {
var ghostServer;
function doEnd(done) {
@@ -53,7 +56,7 @@ describe('Channel Routes', function () {
sandbox.restore();
});
- describe('Index', function () {
+ describe('Collection Index', function () {
it('should respond with html', function (done) {
request.get('/')
.expect('Content-Type', /html/)
@@ -240,6 +243,34 @@ describe('Channel Routes', function () {
});
});
+ describe('Collection Entry', function () {
+ before(function () {
+ return testUtils.initData().then(function () {
+ return testUtils.fixtures.overrideOwnerUser();
+ }).then(function () {
+ return testUtils.fixtures.insertPostsAndTags();
+ });
+ });
+
+ it('should render page with slug permalink', function (done) {
+ request.get('/static-page-test/')
+ .expect('Content-Type', /html/)
+ .expect('Cache-Control', testUtils.cacheRules.public)
+ .expect(200)
+ .end(doEnd(done));
+ });
+
+ it('should not render page with dated permalink', function (done) {
+ const date = moment().format('YYYY/MM/DD');
+
+ request.get('/' + date + '/static-page-test/')
+ .expect('Content-Type', /html/)
+ .expect('Cache-Control', testUtils.cacheRules.private)
+ .expect(404)
+ .end(doEnd(done));
+ });
+ });
+
describe('Tag', function () {
before(function (done) {
testUtils.clearData().then(function () {
@@ -325,6 +356,32 @@ describe('Channel Routes', function () {
});
});
+ describe('Edit', function () {
+ it('should redirect without slash', function (done) {
+ request.get('/tag/getting-started/edit')
+ .expect('Location', '/tag/getting-started/edit/')
+ .expect('Cache-Control', testUtils.cacheRules.year)
+ .expect(301)
+ .end(doEnd(done));
+ });
+
+ it('should redirect to tag settings', function (done) {
+ request.get('/tag/getting-started/edit/')
+ .expect('Location', '/ghost/#/settings/tags/getting-started/')
+ .expect('Cache-Control', testUtils.cacheRules.public)
+ .expect(302)
+ .end(doEnd(done));
+ });
+
+ it('should 404 for non-edit parameter', function (done) {
+ request.get('/tag/getting-started/notedit/')
+ .expect('Cache-Control', testUtils.cacheRules.private)
+ .expect(404)
+ .expect(/Page not found/)
+ .end(doEnd(done));
+ });
+ });
+
describe('Paged', function () {
before(testUtils.teardown);
@@ -409,32 +466,6 @@ describe('Channel Routes', function () {
});
});
});
-
- describe('Edit', function () {
- it('should redirect without slash', function (done) {
- request.get('/tag/getting-started/edit')
- .expect('Location', '/tag/getting-started/edit/')
- .expect('Cache-Control', testUtils.cacheRules.year)
- .expect(301)
- .end(doEnd(done));
- });
-
- it('should redirect to tag settings', function (done) {
- request.get('/tag/getting-started/edit/')
- .expect('Location', '/ghost/#/settings/tags/getting-started/')
- .expect('Cache-Control', testUtils.cacheRules.public)
- .expect(302)
- .end(doEnd(done));
- });
-
- it('should 404 for non-edit parameter', function (done) {
- request.get('/tag/getting-started/notedit/')
- .expect('Cache-Control', testUtils.cacheRules.private)
- .expect(404)
- .expect(/Page not found/)
- .end(doEnd(done));
- });
- });
});
describe('Author', function () {
@@ -549,6 +580,32 @@ describe('Channel Routes', function () {
});
});
+ describe('Edit', function () {
+ it('should redirect without slash', function (done) {
+ request.get('/author/ghost-owner/edit')
+ .expect('Location', '/author/ghost-owner/edit/')
+ .expect('Cache-Control', testUtils.cacheRules.year)
+ .expect(301)
+ .end(doEnd(done));
+ });
+
+ it('should redirect to editor', function (done) {
+ request.get('/author/ghost-owner/edit/')
+ .expect('Location', '/ghost/#/team/ghost-owner/')
+ .expect('Cache-Control', testUtils.cacheRules.public)
+ .expect(302)
+ .end(doEnd(done));
+ });
+
+ it('should 404 for something that isn\'t edit', function (done) {
+ request.get('/author/ghost-owner/notedit/')
+ .expect('Cache-Control', testUtils.cacheRules.private)
+ .expect(404)
+ .expect(/Page not found/)
+ .end(doEnd(done));
+ });
+ });
+
describe('Paged', function () {
// Add enough posts to trigger pages
before(function (done) {
@@ -634,31 +691,5 @@ describe('Channel Routes', function () {
});
});
});
-
- describe('Edit', function () {
- it('should redirect without slash', function (done) {
- request.get('/author/ghost-owner/edit')
- .expect('Location', '/author/ghost-owner/edit/')
- .expect('Cache-Control', testUtils.cacheRules.year)
- .expect(301)
- .end(doEnd(done));
- });
-
- it('should redirect to editor', function (done) {
- request.get('/author/ghost-owner/edit/')
- .expect('Location', '/ghost/#/team/ghost-owner/')
- .expect('Cache-Control', testUtils.cacheRules.public)
- .expect(302)
- .end(doEnd(done));
- });
-
- it('should 404 for something that isn\'t edit', function (done) {
- request.get('/author/ghost-owner/notedit/')
- .expect('Cache-Control', testUtils.cacheRules.private)
- .expect(404)
- .expect(/Page not found/)
- .end(doEnd(done));
- });
- });
});
});
diff --git a/core/test/functional/routes/frontend_spec.js b/core/test/functional/routes/frontend_spec.js
index 5cb1192cfb..40feb5c12d 100644
--- a/core/test/functional/routes/frontend_spec.js
+++ b/core/test/functional/routes/frontend_spec.js
@@ -214,6 +214,7 @@ describe('Frontend Routing', function () {
should.not.exist(res.headers['set-cookie']);
should.exist(res.headers.date);
+ // NOTE: This is the title from the settings.
$('title').text().should.equal('Welcome to Ghost');
// @TODO: change or remove?
@@ -301,6 +302,7 @@ describe('Frontend Routing', function () {
should.exist(res.headers.date);
$('title').text().should.equal('Welcome to Ghost');
+
$('.content .post').length.should.equal(1);
$('.poweredby').text().should.equal('Proudly published with Ghost');
$('body.amp-template').length.should.equal(1);
diff --git a/core/test/integration/api/api_posts_spec.js b/core/test/integration/api/api_posts_spec.js
index d3cf838a0e..e7a7c980bb 100644
--- a/core/test/integration/api/api_posts_spec.js
+++ b/core/test/integration/api/api_posts_spec.js
@@ -9,6 +9,7 @@ var should = require('should'),
db = require('../../../server/data/db'),
models = require('../../../server/models'),
PostAPI = require('../../../server/api/posts'),
+ urlService = require('../../../server/services/url'),
settingsCache = require('../../../server/services/settings/cache'),
sandbox = sinon.sandbox.create();
@@ -422,6 +423,8 @@ describe('Post API', function () {
});
it('with context.user can fetch url and author fields', function (done) {
+ sandbox.stub(urlService, 'getUrlByResourceId').withArgs(testUtils.DataGenerator.Content.posts[7].id).returns('/html-ipsum/');
+
PostAPI.browse({context: {user: 1}, status: 'all', limit: 5}).then(function (results) {
should.exist(results.posts);
diff --git a/core/test/integration/data/importer/importers/data_spec.js b/core/test/integration/data/importer/importers/data_spec.js
index f6408fb6f6..94992e223f 100644
--- a/core/test/integration/data/importer/importers/data_spec.js
+++ b/core/test/integration/data/importer/importers/data_spec.js
@@ -220,7 +220,11 @@ describe('Import', function () {
testUtils.fixtures.loadExportFixture('export-001', {lts: true}).then(function (exported) {
exportData = exported;
return dataImporter.doImport(exportData, importOptions);
- }).then(function () {
+ }).then(function (result) {
+ // NOTE: the user in the JSON is not imported (!) - duplicate email
+ result.problems[1].help.should.eql('User');
+ result.problems[1].message.should.eql('Entry was not imported and ignored. Detected duplicated entry.');
+
// Grab the data from tables
return Promise.all([
knex('users').select(),
@@ -248,10 +252,9 @@ describe('Import', function () {
hashedPassword: users[0].password,
plainPassword: testUtils.DataGenerator.Content.users[0].password
}).then(function () {
- // but the name, slug, and bio should have been overridden
users[0].name.should.equal(exportData.data.users[0].name);
users[0].slug.should.equal(exportData.data.users[0].slug);
- should.not.exist(users[0].bio, 'bio is not imported');
+ users[0].email.should.equal(exportData.data.users[0].email);
// import no longer requires all data to be dropped, and adds posts
posts.length.should.equal(exportData.data.posts.length, 'Wrong number of posts');
@@ -323,7 +326,11 @@ describe('Import', function () {
testUtils.fixtures.loadExportFixture('export-002', {lts: true}).then(function (exported) {
exportData = exported;
return dataImporter.doImport(exportData, importOptions);
- }).then(function () {
+ }).then(function (result) {
+ // NOTE: the user in the JSON is not imported (!) - duplicate email
+ result.problems[1].help.should.eql('User');
+ result.problems[1].message.should.eql('Entry was not imported and ignored. Detected duplicated entry.');
+
// Grab the data from tables
return Promise.all([
knex('users').select(),
@@ -351,10 +358,9 @@ describe('Import', function () {
hashedPassword: users[0].password,
plainPassword: testUtils.DataGenerator.Content.users[0].password
}).then(function () {
- // but the name, slug, and bio should have been overridden
users[0].name.should.equal(exportData.data.users[0].name);
users[0].slug.should.equal(exportData.data.users[0].slug);
- should.not.exist(users[0].bio, 'bio is not imported');
+ users[0].email.should.equal(exportData.data.users[0].email);
// import no longer requires all data to be dropped, and adds posts
posts.length.should.equal(exportData.data.posts.length, 'Wrong number of posts');
@@ -390,7 +396,11 @@ describe('Import', function () {
testUtils.fixtures.loadExportFixture('export-003', {lts: true}).then(function (exported) {
exportData = exported;
return dataImporter.doImport(exportData, importOptions);
- }).then(function () {
+ }).then(function (result) {
+ // NOTE: the user in the JSON is not imported (!) - duplicate email
+ result.problems[1].help.should.eql('User');
+ result.problems[1].message.should.eql('Entry was not imported and ignored. Detected duplicated entry.');
+
// Grab the data from tables
return Promise.all([
knex('users').select(),
@@ -413,10 +423,9 @@ describe('Import', function () {
hashedPassword: users[0].password,
plainPassword: testUtils.DataGenerator.Content.users[0].password
}).then(function () {
- // but the name, slug, and bio should have been overridden
users[0].name.should.equal(exportData.data.users[0].name);
users[0].slug.should.equal(exportData.data.users[0].slug);
- should.not.exist(users[0].bio, 'bio is not imported');
+ users[0].email.should.equal(exportData.data.users[0].email);
// test posts
posts.length.should.equal(1, 'Wrong number of posts');
@@ -521,10 +530,8 @@ describe('Import', function () {
hashedPassword: users[0].password,
plainPassword: testUtils.DataGenerator.Content.users[0].password
}).then(function () {
- // but the name, slug, and bio should have been overridden
users[0].name.should.equal('Joe Bloggs');
users[0].slug.should.equal('joe-bloggs');
- should.not.exist(users[0].bio, 'bio is not imported');
// test posts
posts.length.should.equal(1, 'Wrong number of posts');
diff --git a/core/test/unit/helpers/ghost_head_spec.js b/core/test/integration/helpers/ghost_head_spec.js
similarity index 79%
rename from core/test/unit/helpers/ghost_head_spec.js
rename to core/test/integration/helpers/ghost_head_spec.js
index c98b4db296..cedfb02d35 100644
--- a/core/test/unit/helpers/ghost_head_spec.js
+++ b/core/test/integration/helpers/ghost_head_spec.js
@@ -1,60 +1,337 @@
-var should = require('should'),
+const should = require('should'),
sinon = require('sinon'),
_ = require('lodash'),
- Promise = require('bluebird'),
moment = require('moment'),
testUtils = require('../../utils'),
configUtils = require('../../utils/configUtils'),
models = require('../../../server/models'),
+ routing = require('../../../server/services/routing'),
helpers = require('../../../server/helpers'),
- imageLib = require('../../../server/lib/image'),
proxy = require('../../../server/helpers/proxy'),
settingsCache = proxy.settingsCache,
- api = proxy.api,
- labs = proxy.labs,
-
sandbox = sinon.sandbox.create();
+/**
+ * @TODO: this is not a full unit test. it's a half integration test, because we don't mock "getMetaData"!!
+ * either move to integration tests or rewrite!!!
+ */
describe('{{ghost_head}} helper', function () {
+ let posts = [], tags = [], users = [], knexMock, firstCollection;
+
before(function () {
models.init();
+
+ firstCollection = sandbox.stub();
+ firstCollection.getRssUrl = sandbox.stub().returns('http://localhost:65530/rss/');
+ sandbox.stub(routing.registry, 'getFirstCollectionRouter').returns(firstCollection);
+
+ testUtils.integrationTesting.defaultMocks(sandbox);
+
+ settingsCache.get.withArgs('title').returns('Ghost');
+ settingsCache.get.withArgs('description').returns('blog description');
+ settingsCache.get.withArgs('cover_image').returns('/content/images/blog-cover.png');
+ settingsCache.get.withArgs('amp').returns(true);
+
+ knexMock = new testUtils.mocks.knex();
+ knexMock.mock();
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'all about our blog',
+ title: 'About',
+ feature_image: '/content/images/test-image-about.png',
+ page: true,
+ authors: [testUtils.DataGenerator.forKnex.createUser({
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser',
+ })]
+ }), {withRelated: ['authors']}).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'all about our blog',
+ title: 'About',
+ feature_image: '/content/images/test-image-about.png',
+ og_image: '/content/images/test-og-image.png',
+ og_title: 'Custom Facebook title',
+ og_description: 'Custom Facebook description',
+ twitter_image: '/content/images/test-twitter-image.png',
+ twitter_title: 'Custom Twitter title',
+ twitter_description: 'Custom Twitter description',
+ page: true,
+ authors: [testUtils.DataGenerator.forKnex.createUser({
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })]
+ }), {withRelated: ['authors']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'blog description',
+ title: 'Welcome to Ghost',
+ feature_image: '/content/images/test-image.png',
+ og_title: 'Custom Facebook title',
+ og_description: 'Custom Facebook description',
+ twitter_image: '/content/images/test-twitter-image.png',
+ published_at: moment('2008-05-31T19:18:15').toDate(),
+ updated_at: moment('2014-10-06T15:23:54').toDate(),
+ tags: [
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag1'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag2'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag3'})
+ ],
+ authors: [testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ slug: 'author2',
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ bio: 'Author bio',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'blog description',
+ custom_excerpt: 'post custom excerpt',
+ title: 'Welcome to Ghost',
+ feature_image: '/content/images/test-image.png',
+ og_image: '/content/images/test-facebook-image.png',
+ twitter_image: '/content/images/test-twitter-image.png',
+ twitter_title: 'Custom Twitter title',
+ tags: [
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag1'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag2'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag3'})
+ ],
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ url: 'http://testauthorurl.com',
+ slug: 'author3',
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser',
+ bio: 'Author bio'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ title: 'Welcome to Ghost',
+ mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('This is a short post'),
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ url: 'http://testauthorurl.com',
+ slug: 'author4',
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'blog description',
+ title: 'Welcome to Ghost',
+ feature_image: '/content/images/test-image.png',
+ og_image: '/content/images/test-facebook-image.png',
+ og_title: 'Custom Facebook title',
+ twitter_image: '/content/images/test-twitter-image.png',
+ twitter_title: 'Custom Twitter title',
+ tags: [
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag1'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag2'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag3'})
+ ],
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ url: 'http://testauthorurl.com',
+ slug: 'author5',
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'blog "test" description',
+ title: 'title',
+ meta_title: 'Welcome to Ghost "test"',
+ feature_image: '/content/images/test-image.png',
+ tags: [
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag1'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag2'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag3'})
+ ],
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ url: 'http://testauthorurl.com',
+ slug: 'author6',
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'blog description',
+ title: 'Welcome to Ghost',
+ feature_image: '/content/images/test-image.png',
+ tags: [],
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ url: 'http://testauthorurl.com',
+ slug: 'author7',
+ profile_image: '/content/images/test-author-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ meta_description: 'blog description',
+ title: 'Welcome to Ghost',
+ feature_image: null,
+ tags: [
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag1'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag2'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag3'})
+ ],
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ url: 'http://testauthorurl.com',
+ slug: 'author8',
+ profile_image: null,
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Post.add(testUtils.DataGenerator.forKnex.createPost({
+ title: 'Welcome to Ghost',
+ mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('This is a short post'),
+ tags: [
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag1'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag2'}),
+ testUtils.DataGenerator.forKnex.createTag({name: 'tag3'})
+ ],
+ authors: [
+ testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name'
+ })
+ ]
+ }), {withRelated: ['authors', 'tags']});
+ }).then(function (post) {
+ posts.push(post.toJSON());
+
+ return models.Tag.add(testUtils.DataGenerator.forKnex.createTag({
+ meta_description: 'tag meta description',
+ name: 'tagtitle',
+ meta_title: 'tag meta title',
+ feature_image: '/content/images/tag-image.png'
+ }));
+ }).then(function (tag) {
+ tags.push(tag.toJSON());
+
+ return models.Tag.add(testUtils.DataGenerator.forKnex.createTag({
+ meta_description: '',
+ description: 'tag description',
+ name: 'tagtitle',
+ meta_title: '',
+ feature_image: '/content/images/tag-image.png'
+ }));
+ }).then(function (tag) {
+ tags.push(tag.toJSON());
+
+ return models.Tag.add(testUtils.DataGenerator.forKnex.createTag({
+ meta_description: '',
+ name: 'tagtitle',
+ meta_title: '',
+ feature_image: '/content/images/tag-image.png'
+ }));
+ }).then(function (tag) {
+ tags.push(tag.toJSON());
+
+ return models.Tag.add(testUtils.DataGenerator.forKnex.createTag({
+ meta_description: 'tag meta description',
+ title: 'tagtitle',
+ meta_title: 'tag meta title',
+ feature_image: '/content/images/tag-image.png'
+ }));
+ }).then(function (tag) {
+ tags.push(tag.toJSON());
+
+ return models.User.add(testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ slug: 'AuthorName',
+ bio: 'Author bio',
+ profile_image: '/content/images/test-author-image.png',
+ cover_image: '/content/images/author-cover-image.png',
+ website: 'http://authorwebsite.com',
+ facebook: 'testuser',
+ twitter: '@testuser'
+ }));
+ }).then(function (user) {
+ users.push(user.toJSON());
+
+ return models.User.add(testUtils.DataGenerator.forKnex.createUser({
+ name: 'Author name',
+ slug: 'AuthorName1',
+ bio: 'Author bio',
+ profile_image: '/content/images/test-author-image.png',
+ cover_image: '/content/images/author-cover-image.png',
+ website: 'http://authorwebsite.com'
+ }));
+ }).then(function (user) {
+ users.push(user.toJSON());
+ }).finally(function () {
+ return testUtils.integrationTesting.urlService.init();
+ });
});
- afterEach(function () {
+ after(function () {
+ knexMock.unmock();
+ testUtils.integrationTesting.urlService.resetGenerators();
sandbox.restore();
configUtils.restore();
});
- beforeEach(function () {
- /**
- * Each test case here requests the image dimensions.
- * The image path is e.g. localhost:port/favicon.ico, but no server is running.
- * If we don't mock the image size utility, we run into lot's of timeouts.
- */
- sandbox.stub(imageLib.imageSize, 'getImageSizeFromUrl').resolves();
-
- sandbox.stub(api.clients, 'read').returns(Promise.resolve({
- clients: [
- {slug: 'ghost-frontend', secret: 'a1bcde23cfe5', status: 'enabled'}
- ]
- }));
-
- sandbox.stub(labs, 'isSet').returns(true);
- });
-
describe('without Code Injection', function () {
- var localSettingsCache = {
- title: 'Ghost',
- description: 'blog description',
- cover_image: '/content/images/blog-cover.png',
- amp: true
- };
-
- beforeEach(function () {
- sandbox.stub(settingsCache, 'get').callsFake(function (key) {
- return localSettingsCache[key];
- });
-
+ before(function () {
configUtils.set('url', 'http://localhost:65530/');
});
@@ -120,30 +397,7 @@ describe('{{ghost_head}} helper', function () {
it('returns structured data on static page', function (done) {
var renderObject = {
- post: {
- meta_description: 'all about our blog',
- title: 'About',
- feature_image: '/content/images/test-image-about.png',
- published_at: moment('2008-05-31T19:18:15').toISOString(),
- updated_at: moment('2014-10-06T15:23:54').toISOString(),
- og_image: '',
- og_title: '',
- og_description: '',
- twitter_image: '',
- twitter_title: '',
- twitter_description: '',
- page: true,
- primary_author: {
- name: 'Author name',
- url: 'http://testauthorurl.com',
- slug: 'Author',
- profile_image: '/content/images/test-author-image.png',
- website: 'http://authorwebsite.com',
- facebook: 'testuser',
- twitter: '@testuser',
- bio: 'Author bio'
- }
- }
+ post: posts[0]
};
helpers.ghost_head(testUtils.createHbsResponse({
@@ -192,30 +446,7 @@ describe('{{ghost_head}} helper', function () {
it('returns structured data on static page with custom post structured data', function (done) {
var renderObject = {
- post: {
- meta_description: 'all about our blog',
- title: 'About',
- feature_image: '/content/images/test-image-about.png',
- og_image: '/content/images/test-og-image.png',
- og_title: 'Custom Facebook title',
- og_description: 'Custom Facebook description',
- twitter_image: '/content/images/test-twitter-image.png',
- twitter_title: 'Custom Twitter title',
- twitter_description: 'Custom Twitter description',
- published_at: moment('2008-05-31T19:18:15').toISOString(),
- updated_at: moment('2014-10-06T15:23:54').toISOString(),
- page: true,
- primary_author: {
- name: 'Author name',
- url: 'http://testauthorurl.com',
- slug: 'Author',
- profile_image: '/content/images/test-author-image.png',
- website: 'http://authorwebsite.com',
- facebook: 'testuser',
- twitter: '@testuser',
- bio: 'Author bio'
- }
- }
+ post: posts[1]
};
helpers.ghost_head(testUtils.createHbsResponse({
@@ -262,294 +493,9 @@ describe('{{ghost_head}} helper', function () {
}).catch(done);
});
- it('returns structured data and schema first tag page with meta description and meta title', function (done) {
- var renderObject = {
- tag: {
- meta_description: 'tag meta description',
- name: 'tagtitle',
- meta_title: 'tag meta title',
- feature_image: '/content/images/tag-image.png'
- }
- };
-
- helpers.ghost_head(testUtils.createHbsResponse({
- renderObject: renderObject,
- locals: {
- relativeUrl: '/tag/tagtitle/',
- context: ['tag'],
- safeVersion: '0.3'
- }
- })).then(function (rendered) {
- should.exist(rendered);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(//);
- rendered.string.should.match(/