Ghost/core/server/controllers/frontend.js
Sebastian Gierlinger 8b4979c7d1 Merge pull request #4919 from ErisDS/pr/4852
Update to PR 4852 ({{navigation}} helper)
2015-02-18 20:00:34 +01:00

623 lines
22 KiB
JavaScript

/**
* Main controller for Ghost frontend
*/
/*global require, module */
var moment = require('moment'),
RSS = require('rss'),
_ = require('lodash'),
url = require('url'),
Promise = require('bluebird'),
api = require('../api'),
config = require('../config'),
filters = require('../filters'),
template = require('../helpers/template'),
errors = require('../errors'),
cheerio = require('cheerio'),
routeMatch = require('path-match')(),
frontendControllers,
staticPostPermalink,
// Cache static post permalink regex
staticPostPermalink = routeMatch('/:slug/:edit?');
function getPostPage(options) {
return api.settings.read('postsPerPage').then(function (response) {
var postPP = response.settings[0],
postsPerPage = parseInt(postPP.value, 10);
// No negative posts per page, must be number
if (!isNaN(postsPerPage) && postsPerPage > 0) {
options.limit = postsPerPage;
}
options.include = 'author,tags,fields';
return api.posts.browse(options);
});
}
/**
* returns a promise with an array of values used in {{navigation}}
* TODO(nsfmc): should this be in the 'prePostsRender' pipeline?
* @return {Promise} containing an array of navigation items
*/
function getSiteNavigation() {
return Promise.resolve(api.settings.read('navigation')).then(function (result) {
if (result && result.settings && result.settings.length) {
return JSON.parse(result.settings[0].value) || [];
}
return [];
});
}
/**
* formats variables for handlebars in multi-post contexts.
* If extraValues are available, they are merged in the final value
* TODO(nsfmc): should this be in the 'prePostsRender' pipeline?
* @return {Promise} containing page variables
*/
function formatPageResponse(posts, page, extraValues) {
// Delete email from author for frontend output
// TODO: do this on API level if no context is available
posts = _.each(posts, function (post) {
if (post.author) {
delete post.author.email;
}
return post;
});
extraValues = extraValues || {};
return getSiteNavigation().then(function (navigation) {
var resp = {
posts: posts,
pagination: page.meta.pagination,
navigation: navigation || {}
};
return _.extend(resp, extraValues);
});
}
/**
* similar to formatPageResponse, but for single post pages
* TODO(nsfmc): should this be in the 'prePostsRender' pipeline?
* @return {Promise} containing page variables
*/
function formatResponse(post) {
// Delete email from author for frontend output
// TODO: do this on API level if no context is available
if (post.author) {
delete post.author.email;
}
return getSiteNavigation().then(function (navigation) {
return {
post: post,
navigation: navigation
};
});
}
function handleError(next) {
return function (err) {
return next(err);
};
}
function setResponseContext(req, res, data) {
var contexts = [],
pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1;
// paged context
if (!isNaN(pageParam) && pageParam > 1) {
contexts.push('paged');
}
if (req.route.path === '/page/:page/') {
contexts.push('index');
} else if (req.route.path === '/') {
contexts.push('home');
contexts.push('index');
} else if (/\/rss\/(:page\/)?$/.test(req.route.path)) {
contexts.push('rss');
} else if (/^\/tag\//.test(req.route.path)) {
contexts.push('tag');
} else if (/^\/author\//.test(req.route.path)) {
contexts.push('author');
} else if (data && data.post && data.post.page) {
contexts.push('page');
} else {
contexts.push('post');
}
res.locals.context = contexts;
}
// Add Request context parameter to the data object
// to be passed down to the templates
function setReqCtx(req, data) {
(Array.isArray(data) ? data : [data]).forEach(function (d) {
d.secure = req.secure;
});
}
/**
* Returns the paths object of the active theme via way of a promise.
* @return {Promise} The promise resolves with the value of the paths.
*/
function getActiveThemePaths() {
return api.settings.read({
key: 'activeTheme',
context: {
internal: true
}
}).then(function (response) {
var activeTheme = response.settings[0],
paths = config.paths.availableThemes[activeTheme.value];
return paths;
});
}
frontendControllers = {
homepage: function (req, res, next) {
// Parse the page number
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
options = {
page: pageParam
};
// No negative pages, or page 1
if (isNaN(pageParam) || pageParam < 1 || (pageParam === 1 && req.route.path === '/page/:page/')) {
return res.redirect(config.paths.subdir + '/');
}
return getPostPage(options).then(function (page) {
// If page is greater than number of pages we have, redirect to last page
if (pageParam > page.meta.pagination.pages) {
return res.redirect(page.meta.pagination.pages === 1 ? config.paths.subdir + '/' : (config.paths.subdir + '/page/' + page.meta.pagination.pages + '/'));
}
setReqCtx(req, page.posts);
// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
getActiveThemePaths().then(function (paths) {
var view = paths.hasOwnProperty('home.hbs') ? 'home' : 'index';
// If we're on a page then we always render the index
// template.
if (pageParam > 1) {
view = 'index';
}
setResponseContext(req, res);
formatPageResponse(posts, page).then(function (result) {
res.render(view, result);
});
});
});
}).catch(handleError(next));
},
tag: function (req, res, next) {
// Parse the page number
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
options = {
page: pageParam,
tag: req.params.slug
};
// Get url for tag page
function tagUrl(tag, page) {
var url = config.paths.subdir + '/tag/' + tag + '/';
if (page && page > 1) {
url += 'page/' + page + '/';
}
return url;
}
// No negative pages, or page 1
if (isNaN(pageParam) || pageParam < 1 || (req.params.page !== undefined && pageParam === 1)) {
return res.redirect(tagUrl(options.tag));
}
return getPostPage(options).then(function (page) {
// If page is greater than number of pages we have, redirect to last page
if (pageParam > page.meta.pagination.pages) {
return res.redirect(tagUrl(options.tag, page.meta.pagination.pages));
}
setReqCtx(req, page.posts);
if (page.meta.filters.tags) {
setReqCtx(req, page.meta.filters.tags[0]);
}
// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
getActiveThemePaths().then(function (paths) {
var view = template.getThemeViewForTag(paths, options.tag);
// Format data for template
formatPageResponse(posts, page, {
tag: page.meta.filters.tags ? page.meta.filters.tags[0] : ''
}).then(function (result) {
// If the resulting tag is '' then 404.
if (!result.tag) {
return next();
}
setResponseContext(req, res);
res.render(view, result);
});
});
});
}).catch(handleError(next));
},
author: function (req, res, next) {
// Parse the page number
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
options = {
page: pageParam,
author: req.params.slug
};
// Get url for tag page
function authorUrl(author, page) {
var url = config.paths.subdir + '/author/' + author + '/';
if (page && page > 1) {
url += 'page/' + page + '/';
}
return url;
}
// No negative pages, or page 1
if (isNaN(pageParam) || pageParam < 1 || (req.params.page !== undefined && pageParam === 1)) {
return res.redirect(authorUrl(options.author));
}
return getPostPage(options).then(function (page) {
// If page is greater than number of pages we have, redirect to last page
if (pageParam > page.meta.pagination.pages) {
return res.redirect(authorUrl(options.author, page.meta.pagination.pages));
}
setReqCtx(req, page.posts);
if (page.meta.filters.author) {
setReqCtx(req, page.meta.filters.author);
}
// Render the page of posts
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
getActiveThemePaths().then(function (paths) {
var view = paths.hasOwnProperty('author.hbs') ? 'author' : 'index';
// Format data for template
formatPageResponse(posts, page, {
author: page.meta.filters.author ? page.meta.filters.author : ''
}).then(function (result) {
// If the resulting author is '' then 404.
if (!result.author) {
return next();
}
setResponseContext(req, res);
res.render(view, result);
});
});
});
}).catch(handleError(next));
},
single: function (req, res, next) {
var path = req.path,
params,
usingStaticPermalink = false;
api.settings.read('permalinks').then(function (response) {
var permalink = response.settings[0],
editFormat,
postLookup,
match;
editFormat = permalink.value[permalink.value.length - 1] === '/' ? ':edit?' : '/:edit?';
// Convert saved permalink into a path-match function
permalink = routeMatch(permalink.value + editFormat);
match = permalink(path);
// Check if the path matches the permalink structure.
//
// If there are no matches found we then
// need to verify it's not a static post,
// and test against that permalink structure.
if (match === false) {
match = staticPostPermalink(path);
// If there are still no matches then return.
if (match === false) {
// Reject promise chain with type 'NotFound'
return Promise.reject(new errors.NotFoundError());
}
usingStaticPermalink = true;
}
params = match;
// Sanitize params we're going to use to lookup the post.
postLookup = _.pick(params, 'slug', 'id');
// Add author, tag and fields
postLookup.include = 'author,tags,fields';
// Query database to find post
return api.posts.read(postLookup);
}).then(function (result) {
var post = result.posts[0],
slugDate = [],
slugFormat = [];
if (!post) {
return next();
}
function render() {
// If we're ready to render the page but the last param is 'edit' then we'll send you to the edit page.
if (params.edit) {
params.edit = params.edit.toLowerCase();
}
if (params.edit === 'edit') {
return res.redirect(config.paths.subdir + '/ghost/editor/' + post.id + '/');
} else if (params.edit !== undefined) {
// reject with type: 'NotFound'
return Promise.reject(new errors.NotFoundError());
}
setReqCtx(req, post);
filters.doFilter('prePostsRender', post).then(function (post) {
getActiveThemePaths().then(function (paths) {
var view = template.getThemeViewForPost(paths, post);
return formatResponse(post).then(function (response) {
setResponseContext(req, res, response);
res.render(view, response);
});
});
});
}
// If we've checked the path with the static permalink structure
// then the post must be a static post.
// If it is not then we must return.
if (usingStaticPermalink) {
if (post.page) {
return render();
}
return next();
}
// If there is an author parameter in the slug, check that the
// post is actually written by the given author\
if (params.author) {
if (post.author.slug === params.author) {
return render();
}
return next();
}
// If there is any date based paramter in the slug
// we will check it against the post published date
// to verify it's correct.
if (params.year || params.month || params.day) {
if (params.year) {
slugDate.push(params.year);
slugFormat.push('YYYY');
}
if (params.month) {
slugDate.push(params.month);
slugFormat.push('MM');
}
if (params.day) {
slugDate.push(params.day);
slugFormat.push('DD');
}
slugDate = slugDate.join('/');
slugFormat = slugFormat.join('/');
if (slugDate === moment(post.published_at).format(slugFormat)) {
return render();
}
return next();
}
return render();
}).catch(function (err) {
// If we've thrown an error message
// of type: 'NotFound' then we found
// no path match.
if (err.type === 'NotFoundError') {
return next();
}
return handleError(next)(err);
});
},
rss: function (req, res, next) {
function isPaginated() {
return req.route.path.indexOf(':page') !== -1;
}
function isTag() {
return req.route.path.indexOf('/tag/') !== -1;
}
function isAuthor() {
return req.route.path.indexOf('/author/') !== -1;
}
// Initialize RSS
var pageParam = req.params.page !== undefined ? parseInt(req.params.page, 10) : 1,
slugParam = req.params.slug,
baseUrl = config.paths.subdir;
if (isTag()) {
baseUrl += '/tag/' + slugParam + '/rss/';
} else if (isAuthor()) {
baseUrl += '/author/' + slugParam + '/rss/';
} else {
baseUrl += '/rss/';
}
// No negative pages, or page 1
if (isNaN(pageParam) || pageParam < 1 || (pageParam === 1 && isPaginated())) {
return res.redirect(baseUrl);
}
return Promise.all([
api.settings.read('title'),
api.settings.read('description'),
api.settings.read('permalinks')
]).then(function (result) {
var options = {};
if (pageParam) { options.page = pageParam; }
if (isTag()) { options.tag = slugParam; }
if (isAuthor()) { options.author = slugParam; }
options.include = 'author,tags,fields';
return api.posts.browse(options).then(function (page) {
var title = result[0].settings[0].value,
description = result[1].settings[0].value,
permalinks = result[2].settings[0],
majorMinor = /^(\d+\.)?(\d+)/,
trimmedVersion = res.locals.version,
siteUrl = config.urlFor('home', {secure: req.secure}, true),
feedUrl = config.urlFor('rss', {secure: req.secure}, true),
maxPage = page.meta.pagination.pages,
feed;
trimmedVersion = trimmedVersion ? trimmedVersion.match(majorMinor)[0] : '?';
if (isTag()) {
if (page.meta.filters.tags) {
title = page.meta.filters.tags[0].name + ' - ' + title;
feedUrl = siteUrl + 'tag/' + page.meta.filters.tags[0].slug + '/rss/';
}
}
if (isAuthor()) {
if (page.meta.filters.author) {
title = page.meta.filters.author.name + ' - ' + title;
feedUrl = siteUrl + 'author/' + page.meta.filters.author.slug + '/rss/';
}
}
feed = new RSS({
title: title,
description: description,
generator: 'Ghost ' + trimmedVersion,
feed_url: feedUrl,
site_url: siteUrl,
ttl: '60'
});
// If page is greater than number of pages we have, redirect to last page
if (pageParam > maxPage) {
return res.redirect(baseUrl + maxPage + '/');
}
setReqCtx(req, page.posts);
setResponseContext(req, res);
filters.doFilter('prePostsRender', page.posts).then(function (posts) {
posts.forEach(function (post) {
var item = {
title: post.title,
guid: post.uuid,
url: config.urlFor('post', {post: post, permalinks: permalinks}, true),
date: post.published_at,
categories: _.pluck(post.tags, 'name'),
author: post.author ? post.author.name : null
},
htmlContent = cheerio.load(post.html, {decodeEntities: false});
if (post.image) {
htmlContent('p').first().before('<img src="' + post.image + '" />');
htmlContent('img').attr('alt', post.title);
}
// convert relative resource urls to absolute
['href', 'src'].forEach(function (attributeName) {
htmlContent('[' + attributeName + ']').each(function (ix, el) {
var baseUrl,
attributeValue,
parsed;
el = htmlContent(el);
attributeValue = el.attr(attributeName);
// if URL is absolute move on to the next element
try {
parsed = url.parse(attributeValue);
if (parsed.protocol) {
return;
}
} catch (e) {
return;
}
// compose an absolute URL
// if the relative URL begins with a '/' use the blog URL (including sub-directory)
// as the base URL, otherwise use the post's URL.
baseUrl = attributeValue[0] === '/' ? siteUrl : item.url;
// prevent double slashes
if (baseUrl.slice(-1) === '/' && attributeValue[0] === '/') {
attributeValue = attributeValue.substr(1);
}
// make sure URL has a trailing slash
try {
parsed = url.parse(attributeValue);
if (parsed.pathname && parsed.pathname.slice(-1) !== '/') {
parsed.pathname += '/';
attributeValue = url.format(parsed);
}
} catch (e) {
// if the URL we've built cannot be parsed, fall back to the unprocessed URL
return;
}
attributeValue = baseUrl + attributeValue;
el.attr(attributeName, attributeValue);
});
});
item.description = htmlContent.html();
feed.item(item);
});
}).then(function () {
res.set('Content-Type', 'application/rss+xml; charset=UTF-8');
res.send(feed.xml());
});
});
}).catch(handleError(next));
}
};
module.exports = frontendControllers;