diff --git a/core/server/api/settings.js b/core/server/api/settings.js index 8ce7740361..9cf411e5d7 100644 --- a/core/server/api/settings.js +++ b/core/server/api/settings.js @@ -60,7 +60,8 @@ updateConfigCache = function () { postsPerPage: (settingsCache.postsPerPage && settingsCache.postsPerPage.value) || 5, permalinks: (settingsCache.permalinks && settingsCache.permalinks.value) || '/:slug/', twitter: (settingsCache.twitter && settingsCache.twitter.value) || '', - facebook: (settingsCache.facebook && settingsCache.facebook.value) || '' + facebook: (settingsCache.facebook && settingsCache.facebook.value) || '', + timezone: (settingsCache.activeTimezone && settingsCache.activeTimezone.value) || 'Europe/Dublin' }, labs: labsValue }); diff --git a/core/server/config/url.js b/core/server/config/url.js index ccc1b08580..73f7d97235 100644 --- a/core/server/config/url.js +++ b/core/server/config/url.js @@ -1,7 +1,7 @@ // Contains all path information to be used throughout // the codebase. -var moment = require('moment'), +var moment = require('moment-timezone'), _ = require('lodash'), ghostConfig = '', // @TODO: unify this with routes.apiBaseUrl @@ -95,18 +95,20 @@ function createUrl(urlPath, absolute, secure) { return urlJoin(base, urlPath); } -// ## urlPathForPost -// Always sync -// Creates the url path for a post, given a post and a permalink -// Parameters: -// - post - a json object representing a post +/** + * 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 = ghostConfig.theme.permalinks, + publishedAtMoment = moment.tz(post.published_at, ghostConfig.theme.timezone), tags = { - year: function () { return moment(post.published_at).format('YYYY'); }, - month: function () { return moment(post.published_at).format('MM'); }, - day: function () { return moment(post.published_at).format('DD'); }, + year: function () { return publishedAtMoment.format('YYYY'); }, + month: function () { return publishedAtMoment.format('MM'); }, + day: function () { return publishedAtMoment.format('DD'); }, author: function () { return post.author.slug; }, slug: function () { return post.slug; }, id: function () { return post.id; } diff --git a/core/server/controllers/frontend/index.js b/core/server/controllers/frontend/index.js index 87f5cd42a5..ffffd9a8a4 100644 --- a/core/server/controllers/frontend/index.js +++ b/core/server/controllers/frontend/index.js @@ -66,11 +66,21 @@ frontendControllers = { return next(); } - // If we're ready to render the page but the last param is 'edit' then we'll send you to the edit page. + // CASE: we only support /:slug format for pages + if (post.page && post.url !== req.path) { + return next(); + } + + // CASE: last param is of url is /edit, redirect to admin if (lookup.isEditURL) { return res.redirect(config.paths.subdir + '/ghost/editor/' + post.id + '/'); } + // CASE: permalink is not valid anymore, we redirect him permanently to the correct one + if (post.url !== req.path) { + return res.redirect(301, post.url); + } + setRequestIsSecure(req, post); filters.doFilter('prePostsRender', post, res.locals) diff --git a/core/server/controllers/frontend/post-lookup.js b/core/server/controllers/frontend/post-lookup.js index 788c4188ee..c9925fc161 100644 --- a/core/server/controllers/frontend/post-lookup.js +++ b/core/server/controllers/frontend/post-lookup.js @@ -16,28 +16,32 @@ function postLookup(postUrl) { postPermalink = config.theme.permalinks, pagePermalink = '/:slug/', isEditURL = false, - matchFunc, + matchFuncPost, + matchFuncPage, + postParams, + pageParams, params; // Convert saved permalink into a path-match function - matchFunc = routeMatch(getEditFormat(postPermalink)); - params = matchFunc(postPath); + matchFuncPost = routeMatch(getEditFormat(postPermalink)); + 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 - if (params === false) { - matchFunc = routeMatch(getEditFormat(pagePermalink)); - params = matchFunc(postPath); + if (postParams === false) { + matchFuncPage = routeMatch(getEditFormat(pagePermalink)); + pageParams = matchFuncPage(postPath); } // If there are still no matches then return empty. - if (params === false) { + if (pageParams === false) { return Promise.resolve(); } + params = postParams || pageParams; + // If params contains edit, and it is equal to 'edit' this is an edit URL if (params.edit && params.edit.toLowerCase() === 'edit') { - postPath = postPath.replace(params.edit + '/', ''); isEditURL = true; } else if (params.edit !== undefined) { // Unknown string in URL, return empty @@ -53,9 +57,12 @@ function postLookup(postUrl) { return api.posts.read(params).then(function then(result) { var post = result.posts[0]; - // If there is no post, or the post has no URL, or it isn't a match for our original lookup, return empty - // This also catches the case where we use the pagePermalink but the post is not a page - if (!post || !post.url || post.url !== postPath) { + 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(); } diff --git a/core/server/data/schema/default-settings.json b/core/server/data/schema/default-settings.json index 7e7de56bae..832e11bd4d 100644 --- a/core/server/data/schema/default-settings.json +++ b/core/server/data/schema/default-settings.json @@ -40,6 +40,12 @@ "isLength": [1, 1000] } }, + "activeTimezone": { + "defaultValue": "Europe/Dublin", + "validations": { + "isNull": false + } + }, "forceI18n": { "defaultValue": "true", "validations": { diff --git a/core/server/data/timezones.json b/core/server/data/timezones.json index 76df1959ed..03c93a4129 100644 --- a/core/server/data/timezones.json +++ b/core/server/data/timezones.json @@ -1,7 +1,7 @@ { "timezones": [ { - "name": "Pacific/Samoa", + "name": "Pacific/Pago_Pago", "label": "(GMT -11:00) Midway Island, Samoa", "offset": -660 }, @@ -11,27 +11,27 @@ "offset": -600 }, { - "name": "US/Alaska", + "name": "America/Anchorage", "label": "(GMT -9:00) Alaska", "offset": -540 }, { - "name": "Mexico/BajaNorte", + "name": "America/Tijuana", "label": "(GMT -8:00) Chihuahua, La Paz, Mazatlan", "offset": -480 }, { - "name": "US/Pacific", + "name": "America/Los_Angeles", "label": "(GMT -8:00) Pacific Time (US & Canada); Tijuana", "offset": -480 }, { - "name": "US/Arizona", + "name": "America/Phoenix", "label": "(GMT -7:00) Arizona", "offset": -420 }, { - "name": "US/Mountain", + "name": "America/Denver", "label": "(GMT -7:00) Mountain Time (US & Canada)", "offset": -420 }, @@ -41,17 +41,17 @@ "offset": -360 }, { - "name": "US/Central", + "name": "America/Chicago", "label": "(GMT -6:00) Central Time (US & Canada)", "offset": -360 }, { - "name": "Mexico/General", + "name": "America/Mexico_City", "label": "(GMT -6:00) Guadalajara, Mexico City, Monterrey", "offset": -360 }, { - "name": "Canada/Saskatchewan", + "name": "America/Regina", "label": "(GMT -6:00) Saskatchewan", "offset": -360 }, @@ -61,12 +61,12 @@ "offset": -300 }, { - "name": "US/Eastern", + "name": "America/New_York", "label": "(GMT -5:00) Eastern Time (US & Canada)", "offset": -300 }, { - "name": "US/East-Indiana", + "name": "America/Fort_Wayne", "label": "(GMT -5:00) Indiana (East)", "offset": -300 }, @@ -76,17 +76,17 @@ "offset": -270 }, { - "name": "Canada/Atlantic", + "name": "America/Halifax", "label": "(GMT -4:00) Atlantic Time (Canada); Brasilia, Greenland", "offset": -240 }, { - "name": "Canada/Newfoundland", + "name": "America/St_Johns", "label": "(GMT -3:30) Newfoundland", "offset": -210 }, { - "name": "America/Buenos_Aires", + "name": "America/Argentina/Buenos_Aires", "label": "(GMT -3:00) Buenos Aires, Georgetown", "offset": -180 }, @@ -141,7 +141,7 @@ "offset": 60 }, { - "name": "Africa/Bangui", + "name": "Africa/Lagos", "label": "(GMT +1:00) West Central Africa", "offset": 60 }, @@ -156,7 +156,7 @@ "offset": 120 }, { - "name": "Africa/Harare", + "name": "Africa/Maputo", "label": "(GMT +2:00) Harare", "offset": 120 }, @@ -191,7 +191,7 @@ "offset": 210 }, { - "name": "Asia/Muscat", + "name": "Asia/Dubai", "label": "(GMT +4:00) Abu Dhabi, Muscat", "offset": 240 }, @@ -216,12 +216,12 @@ "offset": 300 }, { - "name": "Asia/Calcutta", + "name": "Asia/Kolkata", "label": "(GMT +5:30) Chennai, Calcutta, Mumbai, New Delhi", "offset": 330 }, { - "name": "Asia/Katmandu", + "name": "Asia/Kathmandu", "label": "(GMT +5:45) Katmandu", "offset": 345 }, @@ -301,7 +301,7 @@ "offset": 630 }, { - "name": "Australia/Canberra", + "name": "Australia/Sydney", "label": "(GMT +11:00) Canberra, Hobart, Melbourne, Sydney, Vladivostok", "offset": 660 }, diff --git a/core/server/helpers/date.js b/core/server/helpers/date.js index bf39f0874d..17327c41c2 100644 --- a/core/server/helpers/date.js +++ b/core/server/helpers/date.js @@ -1,34 +1,38 @@ // # Date Helper // Usage: `{{date format="DD MM, YYYY"}}`, `{{date updated_at format="DD MM, YYYY"}}` // -// Formats a date using moment.js. Formats published_at by default but will also take a date as a parameter +// Formats a date using moment-timezone.js. Formats published_at by default but will also take a date as a parameter -var moment = require('moment'), - date; +var moment = require('moment-timezone'), + date, + timezone; date = function (date, options) { if (!options && date.hasOwnProperty('hash')) { options = date; date = undefined; + timezone = options.data.blog.timezone; // set to published_at by default, if it's available // otherwise, this will print the current date if (this.published_at) { - date = this.published_at; + date = moment(this.published_at).tz(timezone).format(); } } // ensure that context is undefined, not null, as that can cause errors date = date === null ? undefined : date; - var f = options.hash.format || 'MMM Do, YYYY', - timeago = options.hash.timeago; + var f = options.hash.format || 'MMM DD, YYYY', + timeago = options.hash.timeago, + timeNow = moment().tz(timezone); if (timeago) { - date = moment(date).fromNow(); + date = timezone ? moment(date).tz(timezone).from(timeNow) : moment(date).fromNow(); } else { - date = moment(date).format(f); + date = timezone ? moment(date).tz(timezone).format(f) : moment(date).format(f); } + return date; }; diff --git a/core/test/functional/routes/frontend_spec.js b/core/test/functional/routes/frontend_spec.js index 221bdefec1..4ed2b209f3 100644 --- a/core/test/functional/routes/frontend_spec.js +++ b/core/test/functional/routes/frontend_spec.js @@ -516,7 +516,6 @@ describe('Frontend Routing', function () { }); it('should load a post with date permalink', function (done) { - // get today's date var date = moment().format('YYYY/MM/DD'); request.get('/' + date + '/welcome-to-ghost/') @@ -525,6 +524,20 @@ describe('Frontend Routing', function () { .end(doEnd(done)); }); + it('expect redirect because of wrong/old permalink prefix', function (done) { + var date = moment().format('YYYY/MM/DD'); + + request.get('/2016/04/01/welcome-to-ghost/') + .expect('Content-Type', /html/) + .end(function (err, res) { + res.status.should.eql(301); + request.get('/' + date + '/welcome-to-ghost/') + .expect(200) + .expect('Content-Type', /html/) + .end(doEnd(done)); + }); + }); + it('should serve RSS with date permalink', function (done) { request.get('/rss/') .expect('Content-Type', 'text/xml; charset=utf-8') diff --git a/core/test/unit/config_spec.js b/core/test/unit/config_spec.js index 107cc94cb0..3e96c16301 100644 --- a/core/test/unit/config_spec.js +++ b/core/test/unit/config_spec.js @@ -384,8 +384,8 @@ describe('Config', function () { }); describe('urlPathForPost', function () { - it('should output correct url for post', function () { - configUtils.set({theme: {permalinks: '/:slug/'}}); + it('permalink is /:slug/', function () { + configUtils.set({theme: {permalinks: '/:slug/', timezone: 'Europe/Dublin'}}); var testData = testUtils.DataGenerator.Content.posts[2], postLink = '/short-and-sweet/'; @@ -393,20 +393,17 @@ describe('Config', function () { config.urlPathForPost(testData).should.equal(postLink); }); - it('should output correct url for post with date permalink', function () { - configUtils.set({theme: {permalinks: '/:year/:month/:day/:slug/'}}); + it('permalink is /:year/:month/:day/:slug, blog timezone is Los Angeles', function () { + configUtils.set({theme: {permalinks: '/:year/:month/:day/:slug/', timezone: 'America/Los_Angeles'}}); var testData = testUtils.DataGenerator.Content.posts[2], - today = testData.published_at, - dd = ('0' + today.getDate()).slice(-2), - mm = ('0' + (today.getMonth() + 1)).slice(-2), - yyyy = today.getFullYear(), - postLink = '/' + yyyy + '/' + mm + '/' + dd + '/short-and-sweet/'; + postLink = '/2016/05/17/short-and-sweet/'; + testData.published_at = new Date('2016-05-18T06:30:00.000Z'); config.urlPathForPost(testData).should.equal(postLink); }); - it('should output correct url for page with date permalink', function () { - configUtils.set({theme: {permalinks: '/:year/:month/:day/:slug/'}}); + it('post is page, no permalink usage allowed at all', function () { + configUtils.set({theme: {permalinks: '/:year/:month/:day/:slug/', timezone: 'America/Los_Angeles'}}); var testData = testUtils.DataGenerator.Content.posts[5], postLink = '/static-page-test/'; @@ -414,16 +411,23 @@ describe('Config', function () { config.urlPathForPost(testData).should.equal(postLink); }); - it('should output correct url for post with complex permalink', function () { - configUtils.set({theme: {permalinks: '/:year/:id/:author/'}}); + it('permalink is /:year/:id:/:author', function () { + configUtils.set({theme: {permalinks: '/:year/:id/:author/', timezone: 'America/Los_Angeles'}}); - var testData = _.extend( - {}, testUtils.DataGenerator.Content.posts[2], {id: 3}, {author: {slug: 'joe-bloggs'}} - ), - today = testData.published_at, - yyyy = today.getFullYear(), - postLink = '/' + yyyy + '/3/joe-bloggs/'; + var testData = _.merge(testUtils.DataGenerator.Content.posts[2], {id: 3}, {author: {slug: 'joe-blog'}}), + postLink = '/2015/3/joe-blog/'; + testData.published_at = new Date('2016-01-01T00:00:00.000Z'); + config.urlPathForPost(testData).should.equal(postLink); + }); + + it('permalink is /:year/:id:/:author', function () { + configUtils.set({theme: {permalinks: '/:year/:id/:author/', timezone: 'Europe/Berlin'}}); + + var testData = _.merge(testUtils.DataGenerator.Content.posts[2], {id: 3}, {author: {slug: 'joe-blog'}}), + postLink = '/2016/3/joe-blog/'; + + testData.published_at = new Date('2016-01-01T00:00:00.000Z'); config.urlPathForPost(testData).should.equal(postLink); }); }); diff --git a/core/test/unit/controllers/frontend/index_spec.js b/core/test/unit/controllers/frontend/index_spec.js index 1ec42f48ae..9e05fbf779 100644 --- a/core/test/unit/controllers/frontend/index_spec.js +++ b/core/test/unit/controllers/frontend/index_spec.js @@ -426,16 +426,6 @@ describe('Frontend Controller', function () { frontend.single(req, res, failTest(done)); }); - it('will NOT render post via /YYYY/MM/DD/:slug/ with non-matching date in url', function (done) { - var date = moment(mockPosts[1].published_at).subtract(1, 'days').format('YYYY/MM/DD'); - req.path = '/' + [date, mockPosts[1].posts[0].slug].join('/') + '/'; - - frontend.single(req, res, function () { - res.render.called.should.be.false(); - done(); - }); - }); - it('will NOT render post via /:slug/', function (done) { req.path = '/' + mockPosts[1].posts[0].slug + '/'; diff --git a/core/test/unit/server_helpers/date_spec.js b/core/test/unit/server_helpers/date_spec.js index b62f31cd68..6671f8a33f 100644 --- a/core/test/unit/server_helpers/date_spec.js +++ b/core/test/unit/server_helpers/date_spec.js @@ -6,7 +6,7 @@ var should = require('should'), // Stuff we are testing handlebars = hbs.handlebars, helpers = require('../../../server/helpers'), - moment = require('moment'); + moment = require('moment-timezone'); describe('{{date}} helper', function () { before(function () { @@ -17,22 +17,23 @@ describe('{{date}} helper', function () { should.exist(handlebars.helpers.date); }); - // TODO: When timezone support is added these tests should be updated - // to test the output of the helper against static strings instead - // of calling moment(). Without timezone support the output of this - // helper may differ depending on what timezone the tests are run in. - it('creates properly formatted date strings', function () { var testDates = [ - '2013-12-31T11:28:58.593Z', - '2014-01-01T01:28:58.593Z', - '2014-02-20T01:28:58.593Z', - '2014-03-01T01:28:58.593Z' + '2013-12-31T11:28:58.593+02:00', + '2014-01-01T01:28:58.593+11:00', + '2014-02-20T01:28:58.593-04:00', + '2014-03-01T01:28:58.593+00:00' ], + timezones = 'Europe/Dublin', format = 'MMM Do, YYYY', context = { hash: { format: format + }, + data: { + blog: { + timezone: 'Europe/Dublin' + } } }; @@ -40,20 +41,27 @@ describe('{{date}} helper', function () { var rendered = helpers.date.call({published_at: d}, context); should.exist(rendered); - rendered.should.equal(moment(d).format(format)); + rendered.should.equal(moment(d).tz(timezones).format(format)); }); }); it('creates properly formatted time ago date strings', function () { var testDates = [ - '2013-12-31T23:58:58.593Z', - '2014-01-01T00:28:58.593Z', - '2014-11-20T01:28:58.593Z', - '2014-03-01T01:28:58.593Z' + '2013-12-31T23:58:58.593+02:00', + '2014-01-01T00:28:58.593+11:00', + '2014-11-20T01:28:58.593-04:00', + '2014-03-01T01:28:58.593+00:00' ], + timezones = 'Europe/Dublin', + timeNow = moment().tz('Europe/Dublin'), context = { hash: { timeago: true + }, + data: { + blog: { + timezone: 'Europe/Dublin' + } } }; @@ -61,7 +69,7 @@ describe('{{date}} helper', function () { var rendered = helpers.date.call({published_at: d}, context); should.exist(rendered); - rendered.should.equal(moment(d).fromNow()); + rendered.should.equal(moment(d).tz(timezones).from(timeNow)); }); }); }); diff --git a/package.json b/package.json index 4ea33c446f..6cb04e16ff 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "morgan": "1.7.0", "multer": "1.1.0", "netjet": "1.1.0", + "moment-timezone": "0.5.1", "node-uuid": "1.4.7", "nodemailer": "0.7.1", "oauth2orize": "1.3.0",