diff --git a/core/server/api/canary/webhooks.js b/core/server/api/canary/webhooks.js index 3105670c04..fd8f639c5f 100644 --- a/core/server/api/canary/webhooks.js +++ b/core/server/api/canary/webhooks.js @@ -7,7 +7,10 @@ module.exports = { add: { statusCode: 201, - headers: {}, + headers: { + // NOTE: remove if there is ever a 'read' method + location: false + }, options: [], data: [], permissions: true, diff --git a/core/server/api/shared/headers.js b/core/server/api/shared/headers.js index d1c3caf3ef..af0623197b 100644 --- a/core/server/api/shared/headers.js +++ b/core/server/api/shared/headers.js @@ -1,3 +1,4 @@ +const url = require('url'); const debug = require('ghost-ignition').debug('api:shared:headers'); const Promise = require('bluebird'); const INVALIDATE_ALL = '/*'; @@ -98,28 +99,53 @@ module.exports = { * @description Get header based on ctrl configuration. * * @param {Object} result - API response - * @param {Object} apiConfig + * @param {Object} apiConfigHeaders + * @param {Object} frame * @return {Promise} */ - async get(result, apiConfig = {}) { + async get(result, apiConfigHeaders = {}, frame) { let headers = {}; - if (apiConfig.disposition) { - const dispositionHeader = await disposition[apiConfig.disposition.type](result, apiConfig.disposition); + if (apiConfigHeaders.disposition) { + const dispositionHeader = await disposition[apiConfigHeaders.disposition.type](result, apiConfigHeaders.disposition); if (dispositionHeader) { Object.assign(headers, dispositionHeader); } } - if (apiConfig.cacheInvalidate) { - const cacheInvalidationHeader = cacheInvalidate(result, apiConfig.cacheInvalidate); + if (apiConfigHeaders.cacheInvalidate) { + const cacheInvalidationHeader = cacheInvalidate(result, apiConfigHeaders.cacheInvalidate); if (cacheInvalidationHeader) { Object.assign(headers, cacheInvalidationHeader); } } + const locationHeaderDisabled = apiConfigHeaders && apiConfigHeaders.location === false; + const hasFrameData = frame + && (frame.method === 'add') + && result[frame.docName] + && result[frame.docName][0] + && result[frame.docName][0].id; + + if (!locationHeaderDisabled && hasFrameData) { + const protocol = (frame.original.url.secure === false) ? 'http://' : 'https://'; + const resourceId = result[frame.docName][0].id; + + let locationURL = url.resolve(`${protocol}${frame.original.url.host}`,frame.original.url.pathname); + if (!locationURL.endsWith('/')) { + locationURL += '/'; + } + locationURL += `${resourceId}/`; + + const locationHeader = { + Location: locationURL + }; + + Object.assign(headers, locationHeader); + } + debug(headers); return headers; } diff --git a/core/server/api/shared/http.js b/core/server/api/shared/http.js index 4a81c9c215..f72fbcad94 100644 --- a/core/server/api/shared/http.js +++ b/core/server/api/shared/http.js @@ -1,3 +1,4 @@ +const url = require('url'); const debug = require('ghost-ignition').debug('api:shared:http'); const shared = require('../shared'); const models = require('../../models'); @@ -41,6 +42,11 @@ const http = (apiImpl) => { params: req.params, user: req.user, session: req.session, + url: { + host: req.vhost ? req.vhost.host : req.get('host'), + pathname: url.parse(req.originalUrl || req.url).pathname, + secure: req.secure + }, context: { api_key: apiKey, user: user, diff --git a/core/server/api/v2/webhooks.js b/core/server/api/v2/webhooks.js index 6f031278fd..07ee97487a 100644 --- a/core/server/api/v2/webhooks.js +++ b/core/server/api/v2/webhooks.js @@ -7,7 +7,10 @@ module.exports = { add: { statusCode: 201, - headers: {}, + headers: { + // NOTE: remove if there is ever a 'read' method + location: false + }, options: [], data: [], validation: { diff --git a/test/api-acceptance/admin/integrations_spec.js b/test/api-acceptance/admin/integrations_spec.js index 53b691cf03..8c2fe2cb6a 100644 --- a/test/api-acceptance/admin/integrations_spec.js +++ b/test/api-acceptance/admin/integrations_spec.js @@ -65,14 +65,14 @@ describe('Integrations API', function () { .expect('Content-Type', /json/) .expect('Cache-Control', testUtils.cacheRules.private) .expect(201) - .end(function (err, {body}) { + .end(function (err, res) { if (err) { return done(err); } - should.equal(body.integrations.length, 1); + should.equal(res.body.integrations.length, 1); - const [integration] = body.integrations; + const [integration] = res.body.integrations; should.equal(integration.name, 'Dis-Integrate!!'); should.equal(integration.api_keys.length, 2); @@ -91,6 +91,9 @@ describe('Integrations API', function () { should.exist(secret); secret.length.should.equal(64); + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('integrations/')}${res.body.integrations[0].id}/`); + done(); }); }); @@ -110,14 +113,14 @@ describe('Integrations API', function () { .expect('Content-Type', /json/) .expect('Cache-Control', testUtils.cacheRules.private) .expect(201) - .end(function (err, {body}) { + .end(function (err, res) { if (err) { return done(err); } - should.equal(body.integrations.length, 1); + should.equal(res.body.integrations.length, 1); - const [integration] = body.integrations; + const [integration] = res.body.integrations; should.equal(integration.name, 'Integratatron4000'); should.equal(integration.webhooks.length, 1); @@ -125,6 +128,9 @@ describe('Integrations API', function () { const webhook = integration.webhooks[0]; should.equal(webhook.integration_id, integration.id); + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('integrations/')}${res.body.integrations[0].id}/`); + done(); }); }); diff --git a/test/api-acceptance/admin/invites_spec.js b/test/api-acceptance/admin/invites_spec.js index 4b7c3f5796..d25aeab416 100644 --- a/test/api-acceptance/admin/invites_spec.js +++ b/test/api-acceptance/admin/invites_spec.js @@ -116,6 +116,9 @@ describe('Invites API', function () { mailService.GhostMailer.prototype.send.called.should.be.true(); + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('invites/')}${res.body.invites[0].id}/`); + done(); }); }); diff --git a/test/api-acceptance/admin/labels_spec.js b/test/api-acceptance/admin/labels_spec.js index 55e817cb2b..9c0342ee14 100644 --- a/test/api-acceptance/admin/labels_spec.js +++ b/test/api-acceptance/admin/labels_spec.js @@ -46,6 +46,9 @@ describe('Labels API', function () { jsonResponse.labels.should.have.length(1); jsonResponse.labels[0].name.should.equal(label.name); jsonResponse.labels[0].slug.should.equal(label.name); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('labels/')}${res.body.labels[0].id}/`); }); }); }); diff --git a/test/api-acceptance/admin/members_spec.js b/test/api-acceptance/admin/members_spec.js index 20d4481589..bbd77a56da 100644 --- a/test/api-acceptance/admin/members_spec.js +++ b/test/api-acceptance/admin/members_spec.js @@ -165,6 +165,9 @@ describe('Members API', function () { jsonResponse.members[0].labels.length.should.equal(1); jsonResponse.members[0].labels[0].name.should.equal('test-label'); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('members/')}${res.body.members[0].id}/`); }) .then(() => { return request @@ -206,6 +209,9 @@ describe('Members API', function () { should.exist(jsonResponse.members); jsonResponse.members.should.have.length(1); + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('members/')}${res.body.members[0].id}/`); + return jsonResponse.members[0]; }) .then((newMember) => { diff --git a/test/api-acceptance/admin/notifications_spec.js b/test/api-acceptance/admin/notifications_spec.js index 0dd4621f52..ce7c132e1e 100644 --- a/test/api-acceptance/admin/notifications_spec.js +++ b/test/api-acceptance/admin/notifications_spec.js @@ -49,6 +49,9 @@ describe('Notifications API', function () { should.exist(jsonResponse.notifications[0].location); jsonResponse.notifications[0].location.should.equal('bottom'); jsonResponse.notifications[0].id.should.be.a.String(); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('notifications/')}${res.body.notifications[0].id}/`); }); }); diff --git a/test/api-acceptance/admin/pages_spec.js b/test/api-acceptance/admin/pages_spec.js index 5f787cecc3..8ae5320cd0 100644 --- a/test/api-acceptance/admin/pages_spec.js +++ b/test/api-acceptance/admin/pages_spec.js @@ -75,6 +75,9 @@ describe('Pages API', function () { localUtils.API.checkResponse(res.body.pages[0], 'page'); should.exist(res.headers['x-cache-invalidate']); + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('pages/')}${res.body.pages[0].id}/`); + return models.Post.findOne({ id: res.body.pages[0].id }, testUtils.context.internal); diff --git a/test/api-acceptance/admin/posts_spec.js b/test/api-acceptance/admin/posts_spec.js index 5a31e30c0a..038cd0c5ba 100644 --- a/test/api-acceptance/admin/posts_spec.js +++ b/test/api-acceptance/admin/posts_spec.js @@ -291,6 +291,9 @@ describe('Posts API', function () { res.body.posts[0].url.should.match(new RegExp(`${config.get('url')}/p/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}`)); should.not.exist(res.headers['x-cache-invalidate']); + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('posts/')}${res.body.posts[0].id}/`); + return models.Post.findOne({ id: res.body.posts[0].id, status: 'draft' diff --git a/test/api-acceptance/admin/tags_spec.js b/test/api-acceptance/admin/tags_spec.js index 0cc888d6ea..b31d8705e9 100644 --- a/test/api-acceptance/admin/tags_spec.js +++ b/test/api-acceptance/admin/tags_spec.js @@ -107,6 +107,9 @@ describe('Tag API', function () { localUtils.API.checkResponse(jsonResponse.tags[0], 'tag', ['url']); testUtils.API.isISO8601(jsonResponse.tags[0].created_at).should.be.true(); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('tags/')}${res.body.tags[0].id}/`); }); }); @@ -132,6 +135,9 @@ describe('Tag API', function () { jsonResponse.tags[0].visibility.should.eql('internal'); jsonResponse.tags[0].name.should.eql('#test'); jsonResponse.tags[0].slug.should.eql('hash-test'); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('tags/')}${res.body.tags[0].id}/`); }); }); diff --git a/test/api-acceptance/admin/webhooks_spec.js b/test/api-acceptance/admin/webhooks_spec.js index b7ccd87f30..2c933741ce 100644 --- a/test/api-acceptance/admin/webhooks_spec.js +++ b/test/api-acceptance/admin/webhooks_spec.js @@ -50,6 +50,8 @@ describe('Webhooks API', function () { jsonResponse.webhooks[0].name.should.equal(webhookData.name); jsonResponse.webhooks[0].api_version.should.equal(webhookData.api_version); jsonResponse.webhooks[0].integration_id.should.equal(webhookData.integration_id); + + should.not.exist(res.headers.location); }); }); diff --git a/test/regression/api/canary/admin/posts_spec.js b/test/regression/api/canary/admin/posts_spec.js index 489e904355..123f9ce24b 100644 --- a/test/regression/api/canary/admin/posts_spec.js +++ b/test/regression/api/canary/admin/posts_spec.js @@ -140,6 +140,9 @@ describe('Posts API', function () { should.exist(res.body.posts); should.exist(res.body.posts[0].title); res.body.posts[0].title.should.equal('(Untitled)'); + + should.exist(res.headers.location); + res.headers.location.should.equal(`http://127.0.0.1:2369${localUtils.API.getApiQuery('posts/')}${res.body.posts[0].id}/`); }); }); }); diff --git a/test/unit/api/shared/headers_spec.js b/test/unit/api/shared/headers_spec.js index a2323aee5d..e106a29c39 100644 --- a/test/unit/api/shared/headers_spec.js +++ b/test/unit/api/shared/headers_spec.js @@ -61,4 +61,83 @@ describe('Unit: api/shared/headers', function () { }); }); }); + + describe('location header', function () { + it('adds header when all needed data is present', function () { + const apiResult = { + posts: [{ + id: 'id_value' + }] + }; + + const apiConfigHeaders = {}; + const frame = { + docName: 'posts', + method: 'add', + original: { + url: { + host: 'example.com', + pathname: `/api/canary/posts/` + } + } + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame) + .then((result) => { + result.should.eql({ + // NOTE: the backslash in the end is important to avoid unecessary 301s using the header + Location: 'https://example.com/api/canary/posts/id_value/' + }); + }); + }); + + it('adds and resolves header to correct url when pathname does not contain backslash in the end', function () { + const apiResult = { + posts: [{ + id: 'id_value' + }] + }; + + const apiConfigHeaders = {}; + const frame = { + docName: 'posts', + method: 'add', + original: { + url: { + host: 'example.com', + pathname: `/api/canary/posts` + } + } + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame) + .then((result) => { + result.should.eql({ + // NOTE: the backslash in the end is important to avoid unecessary 301s using the header + Location: 'https://example.com/api/canary/posts/id_value/' + }); + }); + }); + + it('does not add header when missing result values', function () { + const apiResult = {}; + + const apiConfigHeaders = {}; + const frame = { + docName: 'posts', + method: 'add', + original: { + url: { + host: 'example.com', + pathname: `/api/canary/posts/` + } + } + }; + + return shared.headers.get(apiResult, apiConfigHeaders, frame) + .then((result) => { + result.should.eql({}); + }); + }); + }); }); diff --git a/test/unit/api/shared/http_spec.js b/test/unit/api/shared/http_spec.js index d12c4828f6..e4723ac1d8 100644 --- a/test/unit/api/shared/http_spec.js +++ b/test/unit/api/shared/http_spec.js @@ -15,6 +15,10 @@ describe('Unit: api/shared/http', function () { req.body = { a: 'a' }; + req.vhost = { + host: 'example.com' + }; + req.url = 'https://example.com/ghost/api/canary/', res.status = sinon.stub(); res.json = sinon.stub();