From b46f9b1dc26641dfaf3ee652ce39bd34a72d5047 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Sun, 8 Sep 2019 23:59:28 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Fully=20separated=20front-end=20?= =?UTF-8?q?and=20admin=20app=20urls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue - uses `vhost` in parent-app to properly split front-end and admin/api apps when a separate admin url is configured --- core/server/web/parent-app.js | 33 +- .../web/shared/middlewares/admin-redirects.js | 2 +- core/test/regression/site/site_spec.js | 454 ++++++++++++++++++ core/test/unit/web/parent-app_spec.js | 74 ++- package.json | 1 + yarn.lock | 5 + 6 files changed, 552 insertions(+), 17 deletions(-) diff --git a/core/server/web/parent-app.js b/core/server/web/parent-app.js index 4a718f82bb..1d3a48f53f 100644 --- a/core/server/web/parent-app.js +++ b/core/server/web/parent-app.js @@ -1,9 +1,16 @@ const debug = require('ghost-ignition').debug('web:parent'); const express = require('express'); +const vhost = require('vhost'); const config = require('../config'); const compress = require('compression'); const netjet = require('netjet'); const shared = require('./shared'); +const escapeRegExp = require('lodash.escaperegexp'); +const {URL} = require('url'); +const urlUtils = require('../lib/url-utils'); +const storage = require('../adapters/storage'); + +const STATIC_IMAGE_URL_PREFIX = `/${urlUtils.STATIC_IMAGE_URL_PREFIX}`; module.exports = function setupParentApp(options = {}) { debug('ParentApp setup start'); @@ -37,17 +44,31 @@ module.exports = function setupParentApp(options = {}) { // This sets global res.locals which are needed everywhere parentApp.use(shared.middlewares.ghostLocals); + // Wrap the admin and API apps into a single express app for use with vhost + const adminApp = express(); + adminApp.enable('trust proxy'); // required to respect x-forwarded-proto in admin requests + adminApp.use('/ghost/api', require('./api')()); + adminApp.use('/ghost', require('./admin')()); + // TODO: remove /content/* once we're sure the API is not returning relative asset URLs anywhere + adminApp.use(STATIC_IMAGE_URL_PREFIX, shared.middlewares.image.handleImageSizes, storage.getStorage().serve()); + // Mount the apps on the parentApp - // API - // @TODO: finish refactoring the API app - parentApp.use('/ghost/api', require('./api')()); + const adminHost = config.get('admin:url') ? (new URL(config.get('admin:url')).hostname) : ''; + const frontendHost = new URL(config.get('url')).hostname; + const hasSeparateAdmin = adminHost && adminHost !== frontendHost; - // ADMIN - parentApp.use('/ghost', require('./admin')()); + // ADMIN + API + // with a separate admin url only serve on that host, otherwise serve on all hosts + const adminVhostArg = hasSeparateAdmin && adminHost ? adminHost : /.*/; + parentApp.use(vhost(adminVhostArg, adminApp)); // BLOG - parentApp.use(require('./site')(options)); + // with a separate admin url we adjust the frontend vhost to exclude requests to that host, otherwise serve on all hosts + const frontendVhostArg = (hasSeparateAdmin && adminHost) ? + new RegExp(`^(?!${escapeRegExp(adminHost)}).*`) : /.*/; + + parentApp.use(vhost(frontendVhostArg, require('./site')(options))); debug('ParentApp setup end'); diff --git a/core/server/web/shared/middlewares/admin-redirects.js b/core/server/web/shared/middlewares/admin-redirects.js index 7f42fa0cd2..2fe058051e 100644 --- a/core/server/web/shared/middlewares/admin-redirects.js +++ b/core/server/web/shared/middlewares/admin-redirects.js @@ -14,7 +14,7 @@ module.exports = function adminRedirects() { router.get(/^\/(logout|signout)\/$/, adminRedirect('#/signout/')); router.get(/^\/signup\/$/, adminRedirect('#/signup/')); // redirect to /ghost and let that do the authentication to prevent redirects to /ghost//admin etc. - router.get(/^\/((ghost-admin|admin|dashboard|signin|login)\/?)$/, adminRedirect('/')); + router.get(/^\/((ghost|ghost-admin|admin|dashboard|signin|login)\/?)$/, adminRedirect('/')); return router; }; diff --git a/core/test/regression/site/site_spec.js b/core/test/regression/site/site_spec.js index b689ad89b4..e8838b28b0 100644 --- a/core/test/regression/site/site_spec.js +++ b/core/test/regression/site/site_spec.js @@ -5202,4 +5202,458 @@ describe('Integration - Web - Site', function () { }); }); }); + + describe('parent app vhosts', function () { + describe('no separate admin', function () { + before(function () { + testUtils.integrationTesting.urlService.resetGenerators(); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); + testUtils.integrationTesting.overrideGhostConfig(configUtils); + + configUtils.set('url', 'http://example.com'); + configUtils.set('admin:url', null); + + return testUtils.integrationTesting.initGhost() + .then(function () { + sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2'); + sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2); + + app = siteApp({start: true}); + return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); + }); + }); + + before(function () { + configUtils.set('url', 'http://example.com'); + configUtils.set('admin:url', null); + urlUtils.stubUrlUtilsFromConfig(); + }); + + after(function () { + configUtils.restore(); + urlUtils.restore(); + sinon.restore(); + }); + + it('loads the front-end on configured url', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the front-end on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the admin', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the admin on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the api', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the api on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + }); + + describe('separate admin host', function () { + before(function () { + testUtils.integrationTesting.urlService.resetGenerators(); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); + testUtils.integrationTesting.overrideGhostConfig(configUtils); + + configUtils.set('url', 'http://example.com'); + configUtils.set('admin:url', 'https://admin.example.com'); + + return testUtils.integrationTesting.initGhost() + .then(function () { + sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2'); + sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2); + + app = siteApp({start: true}); + return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); + }); + }); + + before(function () { + urlUtils.stubUrlUtilsFromConfig(); + }); + + after(function () { + configUtils.restore(); + urlUtils.restore(); + sinon.restore(); + }); + + it('loads the front-end on configured url', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the front-end on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('redirects /ghost/ on configured url', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://admin.example.com/ghost/'); + }); + }); + + it('404s the api on configured url', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(404); + }); + }); + + it('404s the api on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(404); + }); + }); + + it('loads the admin on configured admin url', function () { + const req = { + secure: true, + method: 'GET', + url: '/ghost/', + host: 'admin.example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the api on configured admin url', function () { + const req = { + secure: true, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'admin.example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('redirects to the correct protocol on configured admin url', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'admin.example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://admin.example.com/ghost/'); + }); + }); + + it('404s the front-end on configured admin url', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'admin.example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(404); + }); + }); + }); + + describe('same host separate protocol', function () { + before(function () { + testUtils.integrationTesting.urlService.resetGenerators(); + testUtils.integrationTesting.defaultMocks(sinon, {amp: true, apps: true}); + testUtils.integrationTesting.overrideGhostConfig(configUtils); + + configUtils.set('url', 'http://example.com'); + configUtils.set('admin:url', 'https://example.com'); + + return testUtils.integrationTesting.initGhost() + .then(function () { + sinon.stub(themeService.getActive(), 'engine').withArgs('ghost-api').returns('v2'); + sinon.stub(themeService.getActive(), 'config').withArgs('posts_per_page').returns(2); + + app = siteApp({start: true}); + return testUtils.integrationTesting.urlService.waitTillFinished(); + }) + .then(() => { + return appsService.init(); + }); + }); + + before(function () { + urlUtils.stubUrlUtilsFromConfig(); + }); + + it('loads the front-end on configured url (http)', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('404s the front-end on configured url (https)', function () { + const req = { + secure: true, + method: 'GET', + url: '/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('loads the front-end on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('redirects /ghost/ on configured url', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://example.com/ghost/'); + }); + }); + + it('redirects /ghost/ on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://example.com/ghost/'); + }); + }); + + it('redirects api to correct protocol on configured admin url', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://example.com/ghost/api/v2/admin/site/'); + }); + }); + + it('loads the admin on configured admin url', function () { + const req = { + secure: true, + method: 'GET', + url: '/ghost/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('redirects the admin on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://example.com/ghost/'); + }); + }); + + it('loads the api on configured admin url', function () { + const req = { + secure: true, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'example.com' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(200); + }); + }); + + it('redirects the api on localhost', function () { + const req = { + secure: false, + method: 'GET', + url: '/ghost/api/v2/admin/site/', + host: 'localhost' + }; + + return testUtils.mocks.express.invoke(app, req) + .then(function (response) { + response.statusCode.should.eql(301); + response.headers.location.should.eql('https://example.com/ghost/api/v2/admin/site/'); + }); + }); + }); + }); }); diff --git a/core/test/unit/web/parent-app_spec.js b/core/test/unit/web/parent-app_spec.js index 214974b9c1..f74ea08c4b 100644 --- a/core/test/unit/web/parent-app_spec.js +++ b/core/test/unit/web/parent-app_spec.js @@ -1,9 +1,11 @@ -var should = require('should'), - sinon = require('sinon'), - proxyquire = require('proxyquire'); +const should = require('should'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); +const configUtils = require('../../utils/configUtils'); describe('parent app', function () { let expressStub; + let vhostSpy; let use; let apiSpy; let parentApp; @@ -19,6 +21,7 @@ describe('parent app', function () { enable: () => {} }); + vhostSpy = sinon.spy(); apiSpy = sinon.spy(); adminSpy = sinon.spy(); siteSpy = sinon.spy(); @@ -27,6 +30,7 @@ describe('parent app', function () { parentApp = proxyquire('../../../server/web/parent-app', { express: expressStub, + vhost: vhostSpy, './api': apiSpy, './admin': adminSpy, './site': siteSpy, @@ -35,20 +39,70 @@ describe('parent app', function () { authPages: authPagesSpy } }); + + configUtils.set('url', 'http://ghost.blog'); }); afterEach(function () { sinon.restore(); + configUtils.restore(); }); - it('should mount 3 apps and assign correct routes to them', function () { - parentApp(); + // url = 'https://ghost.blog' + describe('without separate admin url', function () { + it('should mount and assign correct routes', function () { + parentApp(); - use.calledWith('/ghost/api').should.be.true(); - use.calledWith('/ghost').should.be.true(); + use.calledWith('/ghost/api').should.be.true(); + use.calledWith('/ghost').should.be.true(); + use.calledWith('/content/images').should.be.true(); - apiSpy.called.should.be.true(); - adminSpy.called.should.be.true(); - siteSpy.called.should.be.true(); + apiSpy.called.should.be.true(); + adminSpy.called.should.be.true(); + siteSpy.called.should.be.true(); + + vhostSpy.calledTwice.should.be.true(); + vhostSpy.firstCall.calledWith(/.*/).should.be.true(); + vhostSpy.secondCall.calledWith(/.*/).should.be.true(); + }); + }); + + // url = 'https://ghost.blog' + // admin.url = 'https://admin.ghost.blog' + describe('with separate admin url', function () { + beforeEach(function () { + configUtils.set('admin:url', 'https://admin.ghost.blog'); + }); + + it('should mount and assign correct routes', function () { + parentApp(); + + vhostSpy.calledTwice.should.be.true(); + vhostSpy.firstCall.calledWith('admin.ghost.blog').should.be.true(); + vhostSpy.secondCall.calledWith(/^(?!admin\.ghost\.blog).*/).should.be.true(); + }); + + it('should have regex that excludes admin traffic on front-end', function () { + parentApp(); + const frontendRegex = vhostSpy.secondCall.args[0]; + + frontendRegex.test('localhost').should.be.true(); + frontendRegex.test('ghost.blog').should.be.true(); + frontendRegex.test('admin.ghost.blog').should.be.false(); + }); + }); + + // url = 'http://ghost.blog' + // admin.url = 'https://ghost.blog' + describe('with separate admin protocol', function () { + it('should mount and assign correct routes', function () { + configUtils.set('admin:url', 'https://ghost.blog'); + + parentApp(); + + vhostSpy.calledTwice.should.be.true(); + vhostSpy.firstCall.calledWith(/.*/).should.be.true(); + vhostSpy.secondCall.calledWith(/.*/).should.be.true(); + }); }); }); diff --git a/package.json b/package.json index 27dbc78fb8..1dbb36a4ec 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "simple-html-tokenizer": "0.5.8", "uuid": "3.3.3", "validator": "6.3.0", + "vhost": "3.0.2", "xml": "1.0.1" }, "optionalDependencies": { diff --git a/yarn.lock b/yarn.lock index c0cc03acd7..258b7e180b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8623,6 +8623,11 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vhost@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/vhost/-/vhost-3.0.2.tgz#2fb1decd4c466aa88b0f9341af33dc1aff2478d5" + integrity sha1-L7HezUxGaqiLD5NBrzPcGv8keNU= + video-extensions@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/video-extensions/-/video-extensions-1.1.0.tgz#eaa86b45f29a853c2b873e9d8e23b513712997d6"