0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Adds support for additional permalink structures

fixes #2057

- uses express’ Route object to create RegExp’s
 that we use to check the incoming path
- refactored structure of fronted controller single
 tests to be easier to read
- amend regex to incorporate new allowed permalink
 structure
This commit is contained in:
Harry Wolff 2014-02-02 00:29:07 -05:00
parent d28d65bf5e
commit b066e732dd
4 changed files with 471 additions and 281 deletions

View file

@ -9,13 +9,17 @@ var moment = require('moment'),
_ = require('lodash'),
url = require('url'),
when = require('when'),
Route = require('express').Route,
api = require('../api'),
config = require('../config'),
errors = require('../errorHandling'),
filters = require('../../server/filters'),
frontendControllers;
frontendControllers,
// Cache static post permalink regex
staticPostPermalink = new Route(null, '/:slug/:edit?');
frontendControllers = {
'homepage': function (req, res, next) {
@ -65,22 +69,52 @@ frontendControllers = {
});
},
'single': function (req, res, next) {
// From route check if a date was parsed
// from the regex
var dateInSlug = req.params[0] ? true : false;
when.join(
api.settings.read('permalinks'),
api.posts.read({slug: req.params[1]})
).then(function (promises) {
var permalink = promises[0].value,
post = promises[1];
var path = req.path,
params,
editFormat,
usingStaticPermalink = false;
api.settings.read('permalinks').then(function (permalink) {
editFormat = permalink.value[permalink.value.length - 1] === '/' ? ':edit?' : '/:edit?';
// Convert saved permalink into an express Route object
permalink = new Route(null, permalink.value + editFormat);
// 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 (permalink.match(path) === false) {
// If there are still no matches then return.
if (staticPostPermalink.match(path) === false) {
// Throw specific error
// to break out of the promise chain.
throw new Error('no match');
}
permalink = staticPostPermalink;
usingStaticPermalink = true;
}
params = permalink.params;
// Sanitize params we're going to use to lookup the post.
var postLookup = _.pick(permalink.params, 'slug', 'id');
// Query database to find post
return api.posts.read(postLookup);
}).then(function (post) {
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 (req.params[2] && req.params[2] === 'edit') {
if (params.edit !== undefined) {
return res.redirect(config().paths.subdir + '/ghost/editor/' + post.id + '/');
}
filters.doFilter('prePostsRender', post).then(function (post) {
api.settings.read('activeTheme').then(function (activeTheme) {
var paths = config().paths.availableThemes[activeTheme.value],
@ -90,34 +124,59 @@ frontendControllers = {
});
}
if (!post) {
return next();
}
// Check that the date in the URL matches the published date of the post, else 404
if (dateInSlug && req.params[0] !== moment(post.published_at).format('YYYY/MM/DD/')) {
return next();
}
// A page can only be rendered when there is no date in the url.
// A post can either be rendered with a date in the url depending on the permalink setting.
// For all other conditions return 404.
if (post.page === 1 && dateInSlug === false) {
return render();
}
if (post.page === 0) {
// Handle post rendering
if ((permalink === '/:slug/' && dateInSlug === false) ||
(permalink !== '/:slug/' && dateInSlug === true)) {
// 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 === 1) {
return render();
}
return next();
}
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) {
var slugDate = [],
slugFormat = [];
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();
}
render();
}).otherwise(function (err) {
// If we've thrown an error message
// of 'no match' then we found
// no path match.
if (err.message === 'no match') {
return next();
}
var e = new Error(err.message);
e.status = err.errorCode;
return next(e);

View file

@ -57,8 +57,8 @@
"permalinks": {
"defaultValue": "/:slug/",
"validations": {
"is": "^(/:?[a-z]+){1,}/$",
"regex": "(:id|:slug)",
"is": "^(\/:?[a-z0-9_-]+){1,5}\/$",
"regex": "(:id|:slug|:year|:month|:day)",
"notContains": "/ghost/"
}
}

View file

@ -7,14 +7,6 @@ module.exports = function (server) {
server.get('/rss/', frontend.rss);
server.get('/rss/:page/', frontend.rss);
server.get('/page/:page/', frontend.homepage);
// Only capture the :slug part of the URL
// This regex will always have two capturing groups,
// one for date, and one for the slug.
// Examples:
// Given `/plain-slug/` the req.params would be [undefined, 'plain-slug']
// Given `/2012/12/24/plain-slug/` the req.params would be ['2012/12/24/', 'plain-slug']
// Given `/plain-slug/edit/` the req.params would be [undefined, 'plain-slug', 'edit']
server.get(/^\/([0-9]{4}\/[0-9]{2}\/[0-9]{2}\/)?([^\/.]*)\/$/, frontend.single);
server.get(/^\/([0-9]{4}\/[0-9]{2}\/[0-9]{2}\/)?([^\/.]*)\/edit\/$/, frontend.edit);
server.get('/', frontend.homepage);
server.get('*', frontend.single);
};

View file

@ -161,11 +161,25 @@ describe('Frontend Controller', function () {
'markdown': 'The test normal post content',
'page': 0,
'published_at': new Date('2014/1/2').getTime()
},
// Helper function to prevent unit tests
// from failing via timeout when they
// should just immediately fail
failTest = function(done, msg) {
return function() {
done(new Error(msg));
};
};
beforeEach(function () {
sandbox.stub(api.posts, 'read', function (args) {
return when(args.slug === mockStaticPost.slug ? mockStaticPost : mockPost);
if (args.slug) {
return when(args.slug === mockStaticPost.slug ? mockStaticPost : mockPost);
} else if (args.id) {
return when(args.id === mockStaticPost.id ? mockStaticPost : mockPost);
} else {
return when({});
}
});
apiSettingsStub = sandbox.stub(api.settings, 'read');
@ -191,282 +205,407 @@ describe('Frontend Controller', function () {
}));
});
describe('permalink set to slug', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:slug/'
}));
});
describe('static pages', function () {
it('can render a static page', function (done) {
var req = {
params: [undefined, mockStaticPost.slug]
},
res = {
render: function (view, context) {
assert.equal(view, 'page');
assert.equal(context.post, mockStaticPost);
done();
}
};
describe('permalink set to slug', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:slug/'
}));
});
frontend.single(req, res, null);
});
it('will render static page via /:slug', function (done) {
var req = {
path: '/' + mockStaticPost.slug
},
res = {
render: function (view, context) {
assert.equal(view, 'page');
assert.equal(context.post, mockStaticPost);
done();
}
};
it('will NOT render a static page accessed as a date url', function (done) {
var req = {
params: ['2012/12/30/', mockStaticPost.slug]
},
res = {
render: sinon.spy()
};
frontend.single(req, res, failTest(done));
});
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
it('will NOT render static page via /YYY/MM/DD/:slug', function (done) {
var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug].join('/')
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
it('will redirect static page to admin edit page via /:slug/edit', function (done) {
var req = {
path: '/' + [mockStaticPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: function(arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockStaticPost.id + '/');
done();
}
};
frontend.single(req, res, failTest(done));
});
it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
});
});
});
it('can render a normal post', function (done) {
var req = {
params: [undefined, mockPost.slug]
},
res = {
render: function (view, context) {
assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost);
done();
}
};
frontend.single(req, res, null);
});
it('will NOT render a normal post accessed as a date url', function (done) {
var req = {
params: ['2012/12/30/', mockPost.slug]
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
describe('permalink set to date', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:year/:month/:day/:slug/'
}));
});
});
// Handle Edit append
it('will redirect to admin edit page for a normal post', function (done) {
var req = {
params: [undefined, mockPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: function(arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/');
done();
}
};
it('will render static page via /:slug', function (done) {
var req = {
path: '/' + mockStaticPost.slug
},
res = {
render: function (view, context) {
assert.equal(view, 'page');
assert.equal(context.post, mockStaticPost);
done();
}
};
frontend.single(req, res, null);
});
it('will NOT redirect to admin edit page for a normal post accessed as a date url', function (done) {
var req = {
params: ['2012/12/30/', mockPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
frontend.single(req, res, failTest(done));
});
});
it('will redirect to admin edit page for a static page accessed as a slug', function (done) {
var req = {
params: [undefined, mockStaticPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: function(arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockStaticPost.id + '/');
done();
}
};
it('will NOT render static page via /YYYY/MM/DD/:slug', function (done) {
var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug].join('/')
},
res = {
render: sinon.spy()
};
frontend.single(req, res, null);
});
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
it('will NOT redirect to admin edit page for a static page accessed as a date url', function (done) {
var req = {
params: ['2012/12/30/', mockStaticPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
it('will redirect static page to admin edit page via /:slug/edit', function (done) {
var req = {
path: '/' + [mockStaticPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: function (arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockStaticPost.id + '/');
done();
}
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
frontend.single(req, res, failTest(done));
});
it('will NOT redirect static page to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var req = {
path: '/' + ['2012/12/30', mockStaticPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
});
});
});
});
describe('permalink set to date', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:year/:month/:day/:slug/'
}));
});
describe('post', function () {
describe('permalink set to slug', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:slug'
}));
});
it('can render a static page', function (done) {
var req = {
params: [undefined, mockStaticPost.slug]
},
res = {
render: function (view, context) {
assert.equal(view, 'page');
assert.equal(context.post, mockStaticPost);
done();
}
};
it('will render post via /:slug', function (done) {
var req = {
path: '/' + mockPost.slug
},
res = {
render: function (view, context) {
assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost);
done();
}
};
frontend.single(req, res, null);
});
frontend.single(req, res, failTest(done));
});
it('will NOT render a static page accessed as a date url', function (done) {
var req = {
params: ['2012/12/30/', 'test-static-page']
},
res = {
render: sinon.spy()
};
it('will NOT render post via /YYYY/MM/DD/:slug', function (done) {
var req = {
path: '/' + ['2012/12/30', mockPost.slug].join('/')
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
// Handle Edit append
it('will redirect post to admin edit page via /:slug/edit', function (done) {
var req = {
path: '/' + [mockPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: function(arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/');
done();
}
};
frontend.single(req, res, failTest(done));
});
it('will NOT redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var req = {
path: '/' + ['2012/12/30', mockPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
});
});
});
it('can render a normal post', function (done) {
var date = moment(mockPost.published_at).format('YYYY/MM/DD/'),
req = {
params: [date, mockPost.slug]
},
res = {
render: function (view, context) {
assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost);
done();
}
};
describe('permalink set to date', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:year/:month/:day/:slug'
}));
});
frontend.single(req, res, null);
});
it('will render post via /YYYY/MM/DD/:slug', function (done) {
var date = moment(mockPost.published_at).format('YYYY/MM/DD'),
req = {
path: '/' + [date, mockPost.slug].join('/')
},
res = {
render: function (view, context) {
assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost);
done();
}
};
it('will NOT render a normal post with the wrong date', function (done) {
var date = moment(mockPost.published_at).subtract('days', 1).format('YYYY/MM/DD/'),
req = {
params: [date, mockPost.slug]
},
res = {
render: sinon.spy()
};
frontend.single(req, res, failTest(done));
});
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
it('will NOT render post via /YYYY/MM/DD/:slug with non-matching date in url', function (done) {
var date = moment(mockPost.published_at).subtract('days', 1).format('YYYY/MM/DD'),
req = {
path: '/' + [date, mockPost.slug].join('/')
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
it('will NOT render post via /:slug', function (done) {
var req = {
path: '/' + mockPost.slug
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
// Handle Edit append
it('will redirect post to admin edit page via /YYYY/MM/DD/:slug/edit', function (done) {
var dateFormat = moment(mockPost.published_at).format('YYYY/MM/DD'),
req = {
path: '/' + [dateFormat, mockPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: function (arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/');
done();
}
};
frontend.single(req, res, failTest(done));
});
it('will NOT redirect post to admin edit page via /:slug/edit', function (done) {
var req = {
path: '/' + [mockPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
});
});
});
it('will NOT render a normal post accessed as a slug url', function (done) {
var req = {
params: [undefined, mockPost.slug]
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
describe('permalink set to custom format', function () {
beforeEach(function () {
apiSettingsStub.withArgs('permalinks').returns(when({
value: '/:year/:slug'
}));
});
});
// Handle Edit append
it('will redirect to admin edit page for a normal post', function (done) {
var req = {
params: [moment(mockPost.published_at).format('YYYY/MM/DD/'), mockPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: function (arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/');
done();
}
};
it('will render post via /:year/:slug', function (done) {
var date = moment(mockPost.published_at).format('YYYY'),
req = {
path: '/' + [date, mockPost.slug].join('/')
},
res = {
render: function (view, context) {
assert.equal(view, 'post');
assert(context.post, 'Context object has post attribute');
assert.equal(context.post, mockPost);
done();
}
};
frontend.single(req, res, null);
});
it('will NOT redirect to admin edit page for a normal post accessed as a slug url', function (done) {
var req = {
params: [undefined, mockPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
frontend.single(req, res, failTest(done));
});
});
it('will redirect to admin edit page for a static page accessed as a slug url', function (done) {
var req = {
params: [undefined, mockStaticPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: function (arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockStaticPost.id + '/');
done();
}
};
it('will NOT render post via /YYYY/MM/DD/:slug', function (done) {
var date = moment(mockPost.published_at).format('YYYY/MM/DD'),
req = {
path: '/' + [date, mockPost.slug].join('/')
},
res = {
render: sinon.spy()
};
frontend.single(req, res, null);
});
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
it('will NOT redirect to admin edit page for a static page accessed as a date url', function (done) {
var req = {
params: ['2012/12/30/', mockStaticPost.slug, 'edit']
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
it('will NOT render post via /:year/slug when year does not match post year', function (done) {
var date = moment(mockPost.published_at).subtract('years', 1).format('YYYY'),
req = {
path: '/' + [date, mockPost.slug].join('/')
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
it('will NOT render post via /:slug', function (done) {
var req = {
path: '/' + mockPost.slug
},
res = {
render: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
done();
});
});
// Handle Edit append
it('will redirect post to admin edit page via /:year/:slug/edit', function (done) {
var date = moment(mockPost.published_at).format('YYYY'),
req = {
path: '/' + [date, mockPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: function (arg) {
res.render.called.should.be.false;
arg.should.eql(adminEditPagePath + mockPost.id + '/');
done();
}
};
frontend.single(req, res, failTest(done));
});
it('will NOT redirect post to admin edit page /:slug/edit', function (done) {
var req = {
path: '/' + [mockPost.slug, 'edit'].join('/')
},
res = {
render: sinon.spy(),
redirect: sinon.spy()
};
frontend.single(req, res, function () {
res.render.called.should.be.false;
res.redirect.called.should.be.false;
done();
});
});
});
});