From b066e732dd282cdbf78b4b616eedf438fd5314e4 Mon Sep 17 00:00:00 2001 From: Harry Wolff Date: Sun, 2 Feb 2014 00:29:07 -0500 Subject: [PATCH] Adds support for additional permalink structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/server/controllers/frontend.js | 125 +++-- core/server/data/default-settings.json | 4 +- core/server/routes/frontend.js | 10 +- core/test/unit/frontend_spec.js | 613 +++++++++++++++---------- 4 files changed, 471 insertions(+), 281 deletions(-) diff --git a/core/server/controllers/frontend.js b/core/server/controllers/frontend.js index f716f84716..1c4b20c0e9 100644 --- a/core/server/controllers/frontend.js +++ b/core/server/controllers/frontend.js @@ -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); diff --git a/core/server/data/default-settings.json b/core/server/data/default-settings.json index f637678076..68c714bc57 100644 --- a/core/server/data/default-settings.json +++ b/core/server/data/default-settings.json @@ -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/" } } diff --git a/core/server/routes/frontend.js b/core/server/routes/frontend.js index 2a630c8a5c..f427b9824d 100644 --- a/core/server/routes/frontend.js +++ b/core/server/routes/frontend.js @@ -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); }; \ No newline at end of file diff --git a/core/test/unit/frontend_spec.js b/core/test/unit/frontend_spec.js index f1c8c99de1..6c6fb9ec32 100644 --- a/core/test/unit/frontend_spec.js +++ b/core/test/unit/frontend_spec.js @@ -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(); + }); }); }); });