From b392d1925a9f961d7b4bf781ee86393a7773ed4b Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Tue, 5 Jun 2018 19:02:20 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Dynamic=20Routing=20Beta=20(#9596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs #9601 ### Dynamic Routing This is the beta version of dynamic routing. - we had a initial implementation of "channels" available in the codebase - we have removed and moved this implementation - there is now a centralised place for dynamic routing - server/services/routing - each routing component is represented by a router type e.g. collections, routes, static pages, taxonomies, rss, preview of posts - keep as much as possible logic of routing helpers, middlewares and controllers - ensure test coverage - connect all the things together - yaml file + validation - routing + routers - url service - sitemaps - url access - deeper implementation of yaml validations - e.g. hard require slashes - ensure routing hierarchy/order - e.g. you enable the subscriber app - you have a custom static page, which lives under the same slug /subscribe - static pages are stronger than apps - e.g. the first collection owns the post it has filtered - a post cannot live in two collections - ensure apps are still working and hook into the routers layer (or better said: and register in the routing service) - put as much as possible comments to the code base for better understanding - ensure a clean debug log - ensure we can unmount routes - e.g. you have a collection permalink of /:slug/ represented by {globals.permalink} - and you change the permalink in the admin to dated permalink - the express route get's refreshed from /:slug/ to /:year/:month/:day/:slug/ - unmount without server restart, yey - ensure we are backwards compatible - e.g. render home.hbs for collection index if collection route is / - ensure you can access your configured permalink from the settings table with {globals.permalink} ### Render 503 if url service did not finish - return 503 if the url service has not finished generating the resource urls ### Rewrite sitemaps - we have rewritten the sitemaps "service", because the url generator does no longer happen on runtime - we generate all urls on bootstrap - the sitemaps service will consume created resource and router urls - these urls will be shown on the xml pages - we listen on url events - we listen on router events - we no longer have to fetch the resources, which is nice - the urlservice pre-fetches resources and emits their urls - the urlservice is the only component who knows which urls are valid - i made some ES6 adaptions - we keep the caching logic -> only regenerate xml if there is a change - updated tests - checked test coverage (100%) ### Re-work usage of Url utility - replace all usages of `urlService.utils.urlFor` by `urlService.getByResourceId` - only for resources e.g. post, author, tag - this is important, because with dynamic routing we no longer create static urls based on the settings permalink on runtime - adapt url utility - adapt tests --- content/settings/README.md | 2 +- core/server/api/db.js | 2 +- core/server/apps/amp/lib/router.js | 40 +- .../apps/private-blogging/lib/router.js | 16 +- .../subscribers/lib/helpers/subscribe_form.js | 4 +- core/server/apps/subscribers/lib/router.js | 25 +- core/server/controllers/channel.js | 46 - core/server/controllers/entry.js | 16 +- .../server/controllers/frontend/fetch-data.js | 125 -- .../controllers/frontend/post-lookup.js | 73 - .../controllers/frontend/render-channel.js | 16 - core/server/controllers/index.js | 4 - core/server/controllers/preview.js | 2 +- core/server/controllers/rss.js | 70 - core/server/data/meta/author_url.js | 5 +- core/server/data/meta/rss_url.js | 18 +- core/server/data/meta/url.js | 12 +- .../server/data/xml/sitemap/base-generator.js | 194 +-- core/server/data/xml/sitemap/handler.js | 59 +- .../data/xml/sitemap/index-generator.js | 44 +- core/server/data/xml/sitemap/index.js | 3 - core/server/data/xml/sitemap/manager.js | 120 +- .../server/data/xml/sitemap/page-generator.js | 71 +- .../server/data/xml/sitemap/post-generator.js | 57 +- core/server/data/xml/sitemap/tag-generator.js | 55 +- .../server/data/xml/sitemap/user-generator.js | 66 +- core/server/helpers/author.js | 13 +- core/server/helpers/authors.js | 17 +- core/server/helpers/img_url.js | 4 +- core/server/helpers/proxy.js | 2 +- core/server/helpers/tags.js | 16 +- core/server/index.js | 13 +- core/server/models/base/index.js | 7 +- core/server/models/post.js | 28 +- core/server/models/tag.js | 3 +- core/server/services/apps/proxy.js | 11 +- core/server/services/channels/Channel.js | 62 - core/server/services/channels/Channels2.js | 165 --- .../services/channels/config.channels.json | 41 - core/server/services/channels/index.js | 13 - core/server/services/channels/loader.js | 30 - .../server/services/channels/parent-router.js | 19 - core/server/services/channels/router.js | 93 -- core/server/services/route/app-router.js | 4 - core/server/services/route/index.js | 22 - core/server/services/route/registry.js | 12 - core/server/services/route/site-router.js | 5 - .../services/routing/CollectionRouter.js | 157 ++ .../{route => routing}/ParentRouter.js | 51 +- core/server/services/routing/PreviewRouter.js | 29 + core/server/services/routing/RSSRouter.js | 36 + .../services/routing/StaticPagesRouter.js | 60 + .../services/routing/StaticRoutesRouter.js | 46 + .../server/services/routing/TaxonomyRouter.js | 108 ++ core/server/services/routing/bootstrap.js | 68 + .../routing/controllers/collection.js | 54 + .../services/routing/controllers/entry.js | 78 + .../services/routing/controllers/index.js | 17 + .../services/routing/controllers/preview.js | 42 + .../services/routing/controllers/rss.js | 57 + .../routing/helpers}/context.js | 10 +- .../routing/helpers}/error.js | 0 .../services/routing/helpers/fetch-data.js | 120 ++ .../routing/helpers}/format-response.js | 4 +- core/server/services/routing/helpers/index.js | 41 + .../services/routing/helpers/post-lookup.js | 54 + .../routing/helpers/render-collection.js | 12 + .../routing/helpers}/render-entry.js | 9 +- .../routing/helpers}/renderer.js | 3 +- .../routing/helpers}/secure.js | 0 .../routing/helpers}/templates.js | 78 +- core/server/services/routing/index.js | 21 + .../services/routing/middlewares/index.js | 5 + .../routing/middlewares/page-param.js | 27 + core/server/services/routing/registry.js | 43 + core/server/services/rss/generate-feed.js | 4 +- .../services/settings/default-routes.yaml | 2 +- core/server/services/settings/index.js | 19 +- core/server/services/settings/loader.js | 7 +- core/server/services/settings/validate.js | 205 +++ core/server/services/slack.js | 2 +- core/server/services/url/Queue.js | 14 +- core/server/services/url/Resources.js | 8 + core/server/services/url/UrlGenerator.js | 85 +- core/server/services/url/UrlService.js | 132 +- core/server/services/url/Urls.js | 7 +- core/server/services/url/index.js | 7 +- core/server/services/url/utils.js | 86 +- core/server/services/xmlrpc.js | 2 +- core/server/translations/en.json | 4 +- core/server/web/middleware/error-handler.js | 5 +- core/server/web/middleware/index.js | 5 + core/server/web/middleware/maintenance.js | 9 +- core/server/web/site/app.js | 3 - core/server/web/site/routes.js | 45 +- ...hannel_spec.js => dynamic_routing_spec.js} | 147 +- core/test/functional/routes/frontend_spec.js | 2 + core/test/integration/api/api_posts_spec.js | 3 + .../data/importer/importers/data_spec.js | 29 +- .../helpers/ghost_head_spec.js | 1290 ++++++++--------- .../integration/model/model_posts_spec.js | 19 +- .../services/url/UrlService_spec.js | 737 ++++++++++ core/test/integration/web/site_spec.js | 1094 +++++++++++++- core/test/unit/apps/amp/router_spec.js | 235 ++- core/test/unit/controllers/entry_spec.js | 689 --------- .../unit/controllers/frontend/context_spec.js | 446 ------ .../controllers/frontend/fetch-data_spec.js | 187 --- .../controllers/frontend/post-lookup_spec.js | 256 ---- core/test/unit/controllers/preview_spec.js | 202 --- core/test/unit/controllers/rss_spec.js | 291 ---- core/test/unit/data/meta/amp_url_spec.js | 130 +- core/test/unit/data/meta/author_url_spec.js | 150 +- .../test/unit/data/meta/canonical_url_spec.js | 107 +- .../test/unit/data/meta/paginated_url_spec.js | 14 +- core/test/unit/data/meta/rss_url_spec.js | 31 +- core/test/unit/data/meta/url_spec.js | 182 ++- .../unit/data/xml/sitemap/generator_spec.js | 423 +++--- .../unit/data/xml/sitemap/manager_spec.js | 305 +--- core/test/unit/helpers/author_spec.js | 52 +- core/test/unit/helpers/authors_spec.js | 186 ++- core/test/unit/helpers/tags_spec.js | 285 ++-- core/test/unit/helpers/url_spec.js | 82 +- core/test/unit/models/post_spec.js | 18 +- core/test/unit/services/apps/proxy_spec.js | 10 +- core/test/unit/services/apps/sandbox_spec.js | 16 +- .../services/channels/custom_channels_spec.js | 213 --- .../unit/services/channels/loader_spec.js | 45 - .../services/channels/parent-router_spec.js | 500 ------- .../services/routing/CollectionRouter_spec.js | 157 ++ .../unit/services/routing/RSSRouter_spec.js | 72 + .../routing/controllers/collection_spec.js | 236 +++ .../routing/controllers/entry_spec.js | 181 +++ .../routing/controllers/preview_spec.js | 139 ++ .../services/routing/controllers/rss_spec.js | 181 +++ .../services/routing/helpers/context_spec.js | 302 ++++ .../routing/helpers}/error_spec.js | 21 +- .../routing/helpers/fetch-data_spec.js | 212 +++ .../routing/helpers}/format-response_spec.js | 49 +- .../routing/helpers/post-lookup_spec.js | 234 +++ .../routing/helpers}/templates_spec.js | 124 +- .../routing/middlewares/page-param_spec.js | 77 + .../unit/services/rss/generate-feed_spec.js | 21 +- .../unit/services/settings/loader_spec.js | 6 +- .../unit/services/settings/settings_spec.js | 4 +- .../unit/services/settings/validate_spec.js | 264 ++++ .../services/settings/yaml-parser_spec.js | 2 +- core/test/unit/services/slack_spec.js | 46 +- .../unit/services/url/UrlGenerator_spec.js | 129 +- .../test/unit/services/url/UrlService_spec.js | 845 +++-------- core/test/unit/services/url/utils_spec.js | 116 +- core/test/utils/channelUtils.js | 165 --- core/test/utils/fixtures/data-generator.js | 11 +- .../utils/fixtures/settings/badroutes.yaml | 2 +- .../utils/fixtures/settings/goodroutes.yaml | 2 +- core/test/utils/fixtures/settings/routes.yaml | 12 + .../fixtures/themes/test-theme/default.hbs | 0 .../utils/fixtures/themes/test-theme/home.hbs | 1 + .../fixtures/themes/test-theme/something.hbs | 0 core/test/utils/index.js | 184 ++- core/test/utils/mocks/express.js | 18 +- core/test/utils/mocks/knex.js | 66 +- 161 files changed, 8119 insertions(+), 7742 deletions(-) delete mode 100644 core/server/controllers/channel.js delete mode 100644 core/server/controllers/frontend/fetch-data.js delete mode 100644 core/server/controllers/frontend/post-lookup.js delete mode 100644 core/server/controllers/frontend/render-channel.js delete mode 100644 core/server/controllers/index.js delete mode 100644 core/server/controllers/rss.js delete mode 100644 core/server/data/xml/sitemap/index.js delete mode 100644 core/server/services/channels/Channel.js delete mode 100644 core/server/services/channels/Channels2.js delete mode 100644 core/server/services/channels/config.channels.json delete mode 100644 core/server/services/channels/index.js delete mode 100644 core/server/services/channels/loader.js delete mode 100644 core/server/services/channels/parent-router.js delete mode 100644 core/server/services/channels/router.js delete mode 100644 core/server/services/route/app-router.js delete mode 100644 core/server/services/route/index.js delete mode 100644 core/server/services/route/registry.js delete mode 100644 core/server/services/route/site-router.js create mode 100644 core/server/services/routing/CollectionRouter.js rename core/server/services/{route => routing}/ParentRouter.js (50%) create mode 100644 core/server/services/routing/PreviewRouter.js create mode 100644 core/server/services/routing/RSSRouter.js create mode 100644 core/server/services/routing/StaticPagesRouter.js create mode 100644 core/server/services/routing/StaticRoutesRouter.js create mode 100644 core/server/services/routing/TaxonomyRouter.js create mode 100644 core/server/services/routing/bootstrap.js create mode 100644 core/server/services/routing/controllers/collection.js create mode 100644 core/server/services/routing/controllers/entry.js create mode 100644 core/server/services/routing/controllers/index.js create mode 100644 core/server/services/routing/controllers/preview.js create mode 100644 core/server/services/routing/controllers/rss.js rename core/server/{controllers/frontend => services/routing/helpers}/context.js (93%) rename core/server/{controllers/frontend => services/routing/helpers}/error.js (100%) create mode 100644 core/server/services/routing/helpers/fetch-data.js rename core/server/{controllers/frontend => services/routing/helpers}/format-response.js (93%) create mode 100644 core/server/services/routing/helpers/index.js create mode 100644 core/server/services/routing/helpers/post-lookup.js create mode 100644 core/server/services/routing/helpers/render-collection.js rename core/server/{controllers/frontend => services/routing/helpers}/render-entry.js (70%) rename core/server/{controllers/frontend => services/routing/helpers}/renderer.js (83%) rename core/server/{controllers/frontend => services/routing/helpers}/secure.js (100%) rename core/server/{controllers/frontend => services/routing/helpers}/templates.js (59%) create mode 100644 core/server/services/routing/index.js create mode 100644 core/server/services/routing/middlewares/index.js create mode 100644 core/server/services/routing/middlewares/page-param.js create mode 100644 core/server/services/routing/registry.js create mode 100644 core/server/services/settings/validate.js create mode 100644 core/server/web/middleware/index.js rename core/test/functional/routes/{channel_spec.js => dynamic_routing_spec.js} (95%) rename core/test/{unit => integration}/helpers/ghost_head_spec.js (79%) create mode 100644 core/test/integration/services/url/UrlService_spec.js delete mode 100644 core/test/unit/controllers/entry_spec.js delete mode 100644 core/test/unit/controllers/frontend/context_spec.js delete mode 100644 core/test/unit/controllers/frontend/fetch-data_spec.js delete mode 100644 core/test/unit/controllers/frontend/post-lookup_spec.js delete mode 100644 core/test/unit/controllers/preview_spec.js delete mode 100644 core/test/unit/controllers/rss_spec.js delete mode 100644 core/test/unit/services/channels/custom_channels_spec.js delete mode 100644 core/test/unit/services/channels/loader_spec.js delete mode 100644 core/test/unit/services/channels/parent-router_spec.js create mode 100644 core/test/unit/services/routing/CollectionRouter_spec.js create mode 100644 core/test/unit/services/routing/RSSRouter_spec.js create mode 100644 core/test/unit/services/routing/controllers/collection_spec.js create mode 100644 core/test/unit/services/routing/controllers/entry_spec.js create mode 100644 core/test/unit/services/routing/controllers/preview_spec.js create mode 100644 core/test/unit/services/routing/controllers/rss_spec.js create mode 100644 core/test/unit/services/routing/helpers/context_spec.js rename core/test/unit/{controllers/frontend => services/routing/helpers}/error_spec.js (64%) create mode 100644 core/test/unit/services/routing/helpers/fetch-data_spec.js rename core/test/unit/{controllers/frontend => services/routing/helpers}/format-response_spec.js (57%) create mode 100644 core/test/unit/services/routing/helpers/post-lookup_spec.js rename core/test/unit/{controllers/frontend => services/routing/helpers}/templates_spec.js (82%) create mode 100644 core/test/unit/services/routing/middlewares/page-param_spec.js create mode 100644 core/test/unit/services/settings/validate_spec.js delete mode 100644 core/test/utils/channelUtils.js create mode 100644 core/test/utils/fixtures/settings/routes.yaml create mode 100644 core/test/utils/fixtures/themes/test-theme/default.hbs create mode 100644 core/test/utils/fixtures/themes/test-theme/home.hbs create mode 100644 core/test/utils/fixtures/themes/test-theme/something.hbs 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(/