0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Dynamic Routing Beta (#9596)

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
This commit is contained in:
Katharina Irrgang 2018-06-05 19:02:20 +02:00 committed by GitHub
parent 9b704f1691
commit b392d1925a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
161 changed files with 8119 additions and 7742 deletions

View file

@ -11,7 +11,7 @@ routes:
collections:
/:
route: '{globals.permalinks}'
permalink: '{globals.permalinks}'
template:
- home
- index

View file

@ -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 || {};

View file

@ -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;

View file

@ -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

View file

@ -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') +

View file

@ -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) {

View file

@ -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));
};

View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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);
};
};

View file

@ -1,4 +0,0 @@
module.exports = {
preview: require('./preview'),
entry: require('./entry')
};

View file

@ -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);

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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)) {

View file

@ -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;

View file

@ -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));
});
};

View file

@ -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;

View file

@ -1,3 +0,0 @@
var SiteMapManager = require('./manager');
module.exports = new SiteMapManager();

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 {

View file

@ -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);
}

View file

@ -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!

View file

@ -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')
};

View file

@ -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);
}

View file

@ -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');

View file

@ -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'

View file

@ -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

View file

@ -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]) {

View file

@ -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: {

View file

@ -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;

View file

@ -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: {}
}
});

View file

@ -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/"
}
}

View file

@ -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');

View file

@ -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;
};

View file

@ -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();
};

View file

@ -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;

View file

@ -1,4 +0,0 @@
var ParentRouter = require('./ParentRouter'),
appRouter = new ParentRouter('apps');
module.exports = appRouter;

View file

@ -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');

View file

@ -1,12 +0,0 @@
var _ = require('lodash'),
routes = [];
module.exports = {
set(routerName, route) {
routes.push({route: route, from: routerName});
},
getAll() {
return _.cloneDeep(routes);
}
};

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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();
};

View file

@ -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));
};

View file

@ -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));
};

View file

@ -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');
}
};

View file

@ -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));
};

View file

@ -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));
};

View file

@ -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) {

View file

@ -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;

View file

@ -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
};

View file

@ -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');
}
};

View file

@ -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;

View file

@ -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));
};
};

View file

@ -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));
};
};

View file

@ -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);
};

View file

@ -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);

View file

@ -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');
}
};

View file

@ -0,0 +1,5 @@
module.exports = {
get pageParam() {
return require('./page-param');
}
};

View file

@ -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();
}
};

View file

@ -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 = {};
}
};

View file

@ -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

View file

@ -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

View file

@ -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);
});
// });
},
/**

View file

@ -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;

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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;

View file

@ -246,6 +246,14 @@ class Resources {
this.listeners = [];
this.data = {};
}
softReset() {
this.data = {};
_.each(resourcesConfig, (resourceConfig) => {
this.data[resourceConfig.type] = [];
});
}
}
module.exports = Resources;

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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}."

View file

@ -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

View file

@ -0,0 +1,5 @@
module.exports = {
get maintenance() {
return require('./maintenance');
}
};

View file

@ -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();
};

View file

@ -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());

View file

@ -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();
};

View file

@ -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));
});
});
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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');

Some files were not shown because too many files have changed in this diff Show more