This is an automated email from the ASF dual-hosted git repository. garren pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/couchdb-fauxton.git
The following commit(s) were added to refs/heads/master by this push: new bc94e63 (#947) Refactor CORS addon bc94e63 is described below commit bc94e637fd8b50db6f1febbb7567451d41a41c26 Author: Antonio Maranhao <30349380+antonio-maran...@users.noreply.github.com> AuthorDate: Thu Aug 10 09:15:19 2017 -0400 (#947) Refactor CORS addon * Moved components to individual files * Fixed components. All tests pass * Clean up old code * Remove use of 'var' and changed name of action functions * Use fetch instead of Backbone models * Remove Backbone related files and code cleanup * Jest tests updated --- app/addons/config/layout.js | 2 +- app/addons/config/routes.js | 2 - app/addons/cors/__tests__/actions.test.js | 111 ++++----- app/addons/cors/__tests__/components.test.js | 156 +++++++----- app/addons/cors/__tests__/helpers.test.js | 53 ++++ app/addons/cors/__tests__/resources.test.js | 74 ------ app/addons/cors/__tests__/stores.test.js | 100 -------- app/addons/cors/actions.js | 234 ++++++------------ app/addons/cors/actiontypes.js | 7 +- app/addons/cors/api.js | 120 +++++++++ app/addons/cors/base.js | 8 +- app/addons/cors/components.js | 356 +-------------------------- app/addons/cors/components/CORSContainer.js | 46 ++++ app/addons/cors/components/CORSScreen.js | 150 +++++++++++ app/addons/cors/components/OriginInput.js | 60 +++++ app/addons/cors/components/OriginRow.js | 86 +++++++ app/addons/cors/components/OriginTable.js | 48 ++++ app/addons/cors/components/Origins.js | 45 ++++ app/addons/cors/{base.js => helpers.js} | 33 ++- app/addons/cors/reducers.js | 67 +++++ app/addons/cors/resources.js | 110 --------- app/addons/cors/stores.js | 190 -------------- 22 files changed, 946 insertions(+), 1112 deletions(-) diff --git a/app/addons/config/layout.js b/app/addons/config/layout.js index bc8a504..1a75288 100644 --- a/app/addons/config/layout.js +++ b/app/addons/config/layout.js @@ -51,7 +51,7 @@ export const ConfigLayout = ({showCors, docURL, node, endpoint, crumbs}) => { } ]; const selectedTab = showCors ? 'CORS' : 'Main config'; - const content = showCors ? <CORSComponents.CORSController/> : <ConfigComponents.ConfigTableController node={node} />; + const content = showCors ? <CORSComponents.CORSContainer node={node} url={endpoint}/> : <ConfigComponents.ConfigTableController node={node} />; return ( <div id="dashboard" className="with-sidebar"> <ConfigHeader diff --git a/app/addons/config/routes.js b/app/addons/config/routes.js index 370d126..4b924d4 100644 --- a/app/addons/config/routes.js +++ b/app/addons/config/routes.js @@ -13,7 +13,6 @@ import React from 'react'; import FauxtonAPI from "../../core/api"; import Config from "./resources"; -import CORSActions from "../cors/actions"; import ClusterActions from "../cluster/cluster.actions"; import ConfigActions from "./actions"; import Layout from './layout'; @@ -67,7 +66,6 @@ var ConfigPerNodeRouteObject = FauxtonAPI.RouteObject.extend({ }, configCorsForNode: function (node) { - CORSActions.fetchAndEditCors(node); return <Layout node={node} docURL={this.configs.documentation} diff --git a/app/addons/cors/__tests__/actions.test.js b/app/addons/cors/__tests__/actions.test.js index 124845d..10f7a41 100644 --- a/app/addons/cors/__tests__/actions.test.js +++ b/app/addons/cors/__tests__/actions.test.js @@ -11,7 +11,8 @@ // the License. import utils from "../../../../test/mocha/testUtils"; import FauxtonAPI from "../../../core/api"; -import Actions from "../actions"; +import * as Actions from "../actions"; +import * as CorsAPI from "../api"; import sinon from "sinon"; const assert = utils.assert; @@ -21,103 +22,81 @@ describe('CORS actions', () => { describe('save', () => { - let localNode = 'node2@127.0.0.1'; + const localNode = 'node2@127.0.0.1'; + const baseURL = 'http://localhost:8000/#_config/couchdb@localhost/cors'; + const dispatch = sinon.stub(); + const spyUpdateEnableCorsToHttpd = sinon.stub(CorsAPI, 'updateEnableCorsToHttpd'); + const spyUpdateCorsOrigins = sinon.stub(CorsAPI, 'updateCorsOrigins'); + const spyUpdateCorsCredentials = sinon.stub(CorsAPI, 'updateCorsCredentials'); + const spyUpdateCorsHeaders = sinon.stub(CorsAPI, 'updateCorsHeaders'); + const spyUpdateCorsMethods = sinon.stub(CorsAPI, 'updateCorsMethods'); afterEach(() => { - restore(Actions.saveCorsOrigins); - - restore(FauxtonAPI.when); + restore(FauxtonAPI.Promise.all); restore(FauxtonAPI.addNotification); - }); - - it('should save cors enabled to httpd', () => { - var spy = sinon.spy(Actions, 'saveEnableCorsToHttpd'); - - Actions.saveCors({ - enableCors: false, - node: localNode - }); - assert.ok(spy.calledWith(false)); + spyUpdateEnableCorsToHttpd.reset(); + spyUpdateCorsOrigins.reset(); + spyUpdateCorsCredentials.reset(); + spyUpdateCorsHeaders.reset(); + spyUpdateCorsMethods.reset(); }); - it('does not save cors origins if cors not enabled', () => { - var spy = sinon.spy(Actions, 'saveCorsOrigins'); - - Actions.saveCors({ - enableCors: false, - origins: ['*'], + it('should save enable_cors to httpd', () => { + Actions.saveCors(baseURL, { + corsEnabled: false, node: localNode - }); + })(dispatch); - assert.notOk(spy.calledOnce); + assert.ok(spyUpdateEnableCorsToHttpd.calledWith(baseURL, localNode, false)); }); - it('saves cors origins', () => { - var spy = sinon.spy(Actions, 'saveCorsOrigins'); - - Actions.saveCors({ - enableCors: true, + it('does not save CORS origins if CORS is not enabled', () => { + Actions.saveCors(baseURL, { + corsEnabled: false, origins: ['*'], node: localNode - }); + })(dispatch); - assert.ok(spy.calledWith('*')); + assert.notOk(spyUpdateCorsOrigins.called); }); - it('saves cors allow credentials', () => { - var spy = sinon.spy(Actions, 'saveCorsCredentials'); - - Actions.saveCors({ - enableCors: true, - origins: ['https://testdomain.com'], - node: localNode - }); - - assert.ok(spy.calledOnce); - }); - - it('saves cors headers', () => { - var spy = sinon.spy(Actions, 'saveCorsHeaders'); - - Actions.saveCors({ - enableCors: true, - origins: ['https://testdomain.com'], + it('saves CORS origins', () => { + Actions.saveCors(baseURL, { + corsEnabled: true, + origins: ['*'], node: localNode - }); + })(dispatch); - assert.ok(spy.calledOnce); + assert.ok(spyUpdateCorsOrigins.calledWith(baseURL, localNode, '*')); }); - it('saves cors methods', () => { - var spy = sinon.spy(Actions, 'saveCorsMethods'); - - Actions.saveCors({ - enableCors: true, + it('saves CORS credentials, headers and methods', () => { + Actions.saveCors(baseURL, { + corsEnabled: true, origins: ['https://testdomain.com'], node: localNode - }); - - assert.ok(spy.calledOnce); + })(dispatch); + assert.ok(spyUpdateCorsCredentials.calledOnce); + assert.ok(spyUpdateCorsHeaders.calledOnce); + assert.ok(spyUpdateCorsMethods.calledOnce); }); it('shows notification on successful save', () => { - var stub = sinon.stub(FauxtonAPI, 'when'); - var spy = sinon.spy(FauxtonAPI, 'addNotification'); - var promise = FauxtonAPI.Deferred(); - promise.resolve(); + const stub = sinon.stub(FauxtonAPI.Promise, 'all'); + const spyAddNotification = sinon.spy(FauxtonAPI, 'addNotification'); + const promise = FauxtonAPI.Promise.resolve(); stub.returns(promise); - Actions.saveCors({ + return Actions.saveCors(baseURL, { enableCors: true, origins: ['https://testdomain.com'], node: localNode + })(dispatch).then(() => { + assert.ok(spyAddNotification.called); }); - - assert.ok(spy.calledOnce); }); - }); describe('Sanitize origins', () => { diff --git a/app/addons/cors/__tests__/components.test.js b/app/addons/cors/__tests__/components.test.js index d61f7e9..d264ed9 100644 --- a/app/addons/cors/__tests__/components.test.js +++ b/app/addons/cors/__tests__/components.test.js @@ -10,10 +10,8 @@ // License for the specific language governing permissions and limitations under // the License. import FauxtonAPI from "../../../core/api"; +import * as Helpers from "../helpers"; import Views from "../components"; -import Actions from "../actions"; -import Resources from "../resources"; -import Stores from "../stores"; import utils from "../../../../test/mocha/testUtils"; import React from "react"; import ReactDOM from "react-dom"; @@ -21,19 +19,11 @@ import sinon from "sinon"; import { shallow, mount } from 'enzyme'; FauxtonAPI.router = new FauxtonAPI.Router([]); -const {assert, restore} = utils; -const corsStore = Stores.corsStore; +const { assert, restore } = utils; describe('CORS Components', () => { - describe('CorsController', () => { - - beforeEach(() => { - corsStore._origins = ['http://hello.com']; - corsStore._node = 'node2@127.0.0.1'; - corsStore._isEnabled = true; - corsStore._configChanged = true; - }); + describe('CORSContainer tests', () => { afterEach(() => { restore(window.confirm); @@ -43,7 +33,14 @@ describe('CORS Components', () => { const spy = sinon.stub(window, 'confirm'); spy.returns(false); - const wrapper = shallow(<Views.CORSController />); + const wrapper = shallow(<Views.CORSScreen + corsEnabled={true} + isAllOrigins={false} + origins={['https://localhost']} + saveCORS={sinon.stub()} + showDeleteDomainConfirmation={sinon.stub()} + />); + wrapper.find('.enable-disable .btn').simulate('click'); assert.ok(spy.calledOnce); }); @@ -52,10 +49,13 @@ describe('CORS Components', () => { const spy = sinon.stub(window, 'confirm'); spy.returns(false); - // Set selected origins to empty - corsStore._origins = []; - - const wrapper = shallow(<Views.CORSController />); + const wrapper = shallow(<Views.CORSScreen + corsEnabled={true} + isAllOrigins={true} + origins={[]} + saveCORS={sinon.stub()} + showDeleteDomainConfirmation={sinon.stub()} + />); wrapper.find('.enable-disable .btn').simulate('click'); assert.ok(spy.notCalled); }); @@ -64,8 +64,15 @@ describe('CORS Components', () => { const spy = sinon.stub(window, 'confirm'); spy.returns(false); - const wrapper = mount(<Views.CORSController />); - wrapper.find('input').at(0).simulate('change', {target: {checked: true, value: 'all'}}); + const wrapper = mount(<Views.CORSScreen + corsEnabled={true} + isAllOrigins={false} + origins={['http://localhost']} + saveCORS={sinon.stub()} + showDeleteDomainConfirmation={sinon.stub()} + fetchAndLoadCORSOptions={sinon.stub()} + />); + wrapper.find('input').at(0).simulate('change', { target: { checked: true, value: 'all' } }); assert.ok(spy.calledOnce); }); @@ -73,23 +80,42 @@ describe('CORS Components', () => { const spy = sinon.stub(window, 'confirm'); spy.returns(false); - // Set selected origins to empty - corsStore._origins = []; - - const wrapper = mount(<Views.CORSController />); - wrapper.find('input').at(0).simulate('change', {target: {checked: true, value: 'all'}}); + const wrapper = shallow(<Views.CORSScreen + corsEnabled={true} + isAllOrigins={false} + origins={[]} + saveCORS={sinon.stub()} + showDeleteDomainConfirmation={sinon.stub()} + />); + wrapper.find('input').at(0).simulate('change', { target: { checked: true, value: 'all' } }); assert.notOk(spy.calledOnce); }); it('shows loading bars', () => { - const wrapper = mount(<Views.CORSController />); - Actions.toggleLoadingBarsToEnabled(true); + const wrapper = mount(<Views.CORSScreen + isLoading={true} + corsEnabled={true} + isAllOrigins={false} + origins={[]} + saveCORS={sinon.stub()} + showDeleteDomainConfirmation={sinon.stub()} + fetchAndLoadCORSOptions={sinon.stub()} + />); + assert.ok(wrapper.find('.loading-lines').exists()); }); it('hides loading bars', () => { - const wrapper = mount(<Views.CORSController />); - Actions.toggleLoadingBarsToEnabled(false); + const wrapper = mount(<Views.CORSScreen + isLoading={false} + corsEnabled={true} + isAllOrigins={false} + origins={[]} + saveCORS={sinon.stub()} + showDeleteDomainConfirmation={sinon.stub()} + fetchAndLoadCORSOptions={sinon.stub()} + />); + assert.notOk(wrapper.find('.loading-lines').exists()); }); }); @@ -98,30 +124,29 @@ describe('CORS Components', () => { const newOrigin = 'http://new-site.com'; it('calls validates each domain', () => { - const spyValidateCORSDomain = sinon.spy(Resources, 'validateCORSDomain'); - const addOriginStub = sinon.stub(); - const wrapper = shallow(<Views.OriginInput isVisible={true} addOrigin={addOriginStub}/>); + const spyValidateDomain = sinon.spy(Helpers, 'validateDomain'); + const wrapper = shallow(<Views.OriginInput isVisible={true} addOrigin={sinon.stub()} />); - wrapper.find('input').simulate('change', {target: {value: newOrigin}}); - wrapper.find('.btn').simulate('click', {preventDefault: sinon.stub()}); - assert.ok(spyValidateCORSDomain.calledWith(newOrigin)); + wrapper.find('input').simulate('change', { target: { value: newOrigin } }); + wrapper.find('.btn').simulate('click', { preventDefault: sinon.stub() }); + assert.ok(spyValidateDomain.called); }); it('calls addOrigin on add click with valid domain', () => { const addOriginSpy = sinon.spy(); - const wrapper = shallow(<Views.OriginInput isVisible={true} addOrigin={addOriginSpy}/>); + const wrapper = mount(<Views.OriginInput isVisible={true} addOrigin={addOriginSpy} />); - wrapper.find('input').simulate('change', {target: {value: newOrigin}}); - wrapper.find('.btn').simulate('click', {preventDefault: sinon.stub()}); + wrapper.find('input').simulate('change', { target: { value: newOrigin } }); + wrapper.find('.btn').simulate('click', { preventDefault: sinon.stub() }); assert.ok(addOriginSpy.calledWith(newOrigin)); }); it('shows notification if origin is not valid', () => { const spyAddNotification = sinon.spy(FauxtonAPI, 'addNotification'); - const wrapper = shallow(<Views.OriginInput isVisible={true} addOrigin={sinon.stub()}/>); + const wrapper = shallow(<Views.OriginInput isVisible={true} addOrigin={sinon.stub()} />); - wrapper.find('input').simulate('change', {target: {value: 'badOrigin'}}); - wrapper.find('.btn').simulate('click', {preventDefault: sinon.stub()}); + wrapper.find('input').simulate('change', { target: { value: 'badOrigin' } }); + wrapper.find('.btn').simulate('click', { preventDefault: sinon.stub() }); assert.ok(spyAddNotification.calledOnce); }); }); @@ -134,22 +159,23 @@ describe('CORS Components', () => { }); it('calls changeOrigin() when you switch from "Select List of Origins" to "Allow All Origins"', () => { - const wrapper = shallow(<Views.Origins corsEnabled={true} isAllOrigins={false} originChange={spyChangeOrigin}/>); + const wrapper = shallow(<Views.Origins corsEnabled={true} isAllOrigins={false} originChange={spyChangeOrigin} />); - wrapper.find('input[value="all"]').simulate('change', {target: {checked: true, value: 'all'}}); + wrapper.find('input[value="all"]').simulate('change', { target: { checked: true, value: 'all' } }); assert.ok(spyChangeOrigin.calledWith(true)); }); it('calls changeOrigin() when you switch from "Allow All Origins" to "Select List of Origins"', () => { - const wrapper = shallow(<Views.Origins corsEnabled={true} isAllOrigins={true} originChange={spyChangeOrigin}/>); + const wrapper = shallow(<Views.Origins corsEnabled={true} isAllOrigins={true} originChange={spyChangeOrigin} />); - wrapper.find('input[value="selected"]').simulate('change', {target: {checked: true, value: 'selected'}}); + wrapper.find('input[value="selected"]').simulate('change', { target: { checked: true, value: 'selected' } }); assert.ok(spyChangeOrigin.calledWith(false)); }); }); describe('OriginRow', () => { const spyUpdateOrigin = sinon.spy(); + const spyDeleteOrigin = sinon.spy(); let origin; beforeEach(() => { @@ -158,21 +184,35 @@ describe('CORS Components', () => { afterEach(() => { spyUpdateOrigin.reset(); + spyDeleteOrigin.reset(); }); - it('should show confirm modal on delete', () => { - const wrapper = mount(<Views.OriginTable updateOrigin={spyUpdateOrigin} isVisible={true} origins={[origin]}/>); + it('should call deleteOrigin on delete', () => { + const wrapper = mount(<Views.OriginTable + updateOrigin={spyUpdateOrigin} + deleteOrigin={spyDeleteOrigin} + isVisible={true} + origins={[origin]} />); wrapper.find('.fonticon-trash').simulate('click', { preventDefault: sinon.stub() }); - assert.ok(corsStore.isDeleteDomainModalVisible()); + assert.ok(spyDeleteOrigin.calledOnce); }); it('does not throw error if origins is undefined', () => { - mount(<Views.OriginTable updateOrigin={spyUpdateOrigin} isVisible={true} origins={false}/>); + mount(<Views.OriginTable + updateOrigin={spyUpdateOrigin} + deleteOrigin={spyDeleteOrigin} + isVisible={true} + origins={undefined} />); }); it('should change origin to input on edit click, then hide input on 2nd click', () => { - const wrapper = mount(<Views.OriginTable updateOrigin={spyUpdateOrigin} isVisible={true} origins={[origin]}/>); + const wrapper = mount(<Views.OriginTable + updateOrigin={spyUpdateOrigin} + deleteOrigin={spyDeleteOrigin} + isVisible={true} + origins={[origin]} />); + // Text input appears after clicking Edit wrapper.find('.fonticon-pencil').simulate('click', { preventDefault: sinon.stub() }); assert.ok(wrapper.find('input').exists()); @@ -183,28 +223,32 @@ describe('CORS Components', () => { }); it('should update origin on update clicked', () => { - let updatedOrigin = 'https://updated-origin.com'; + const updatedOrigin = 'https://updated-origin.com'; const wrapper = mount( <Views.OriginTable updateOrigin={spyUpdateOrigin} - isVisible={true} origins={[origin]}/> + deleteOrigin={spyDeleteOrigin} + isVisible={true} + origins={[origin]} /> ); wrapper.find('.fonticon-pencil').simulate('click', { preventDefault: sinon.stub() }); - wrapper.find('input').simulate('change', {target: {value: updatedOrigin}}); + wrapper.find('input').simulate('change', { target: { value: updatedOrigin } }); wrapper.find('.btn').at(0).simulate('click', { preventDefault: sinon.stub() }); assert.ok(spyUpdateOrigin.calledWith(updatedOrigin)); }); it('should not update origin on update clicked with bad origin', () => { - let updatedOrigin = 'updated-origin'; + const updatedOrigin = 'updated-origin'; const wrapper = mount( <Views.OriginTable updateOrigin={spyUpdateOrigin} - isVisible={true} origins={[origin]}/> + deleteOrigin={spyDeleteOrigin} + isVisible={true} + origins={[origin]} /> ); wrapper.find('.fonticon-pencil').simulate('click', { preventDefault: sinon.stub() }); - wrapper.find('input').simulate('change', {target: {value: updatedOrigin}}); + wrapper.find('input').simulate('change', { target: { value: updatedOrigin } }); wrapper.find('.btn').at(0).simulate('click', { preventDefault: sinon.stub() }); assert.notOk(spyUpdateOrigin.calledWith(updatedOrigin)); }); diff --git a/app/addons/cors/__tests__/helpers.test.js b/app/addons/cors/__tests__/helpers.test.js new file mode 100644 index 0000000..d728f4d --- /dev/null +++ b/app/addons/cors/__tests__/helpers.test.js @@ -0,0 +1,53 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. +import testUtils from "../../../../test/mocha/testUtils"; +import * as Helpers from "../helpers"; +const assert = testUtils.assert; + +describe('CORS helper functions', () => { + + it('allows valid domains', () => { + const urls = [ + 'http://something.com', + 'https://a.ca', + 'https://something.com:8000', + 'https://www.some-valid-domain.com:80', + 'http://localhost', + 'https://localhost', + 'http://192.168.1.113', + 'http://192.168.1.113:1337' + ]; + + urls.forEach((url) => { + assert.isTrue(Helpers.validateCORSDomain(url)); + }); + }); + + it('fails on non http/https domains', () => { + const urls = [ + 'whoahnellythisaintright', + 'ftp://site.com', + 'https://', + 'http://' + ]; + _.each(urls, function (url) { + assert.isFalse(Helpers.validateCORSDomain(url)); + }); + }); + + it('normalizes common cases, like accidentally added subfolders', () => { + assert.equal('https://foo.com', Helpers.normalizeUrls('https://foo.com/blerg')); + assert.equal('https://192.168.1.113', Helpers.normalizeUrls('https://192.168.1.113/blerg')); + assert.equal('https://foo.com:1337', Helpers.normalizeUrls('https://foo.com:1337/blerg')); + assert.equal('https://foo.com', Helpers.normalizeUrls('https://foo.com')); + }); +}); diff --git a/app/addons/cors/__tests__/resources.test.js b/app/addons/cors/__tests__/resources.test.js deleted file mode 100644 index 322b168..0000000 --- a/app/addons/cors/__tests__/resources.test.js +++ /dev/null @@ -1,74 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the "License"); you may not -// use this file except in compliance with the License. You may obtain a copy of -// the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations under -// the License. -import testUtils from "../../../../test/mocha/testUtils"; -import CORS from "../resources"; -const assert = testUtils.assert; - -describe('Cors Config Model', () => { - let cors; - - beforeEach(() => { - cors = new CORS.Config(null, { node: 'node2@127.0.0.1' }); - }); - - it('Splits up origins into array', () => { - const origins = ['http://hello.com', 'http://another.co.a']; - cors.set(cors.parse({ origins: origins.join(',') })); - assert.deepEqual(cors.get('origins'), origins); - }); - - it('returns empty array for undefined', () => { - const origins = { origins: undefined }; - cors.set(cors.parse(origins)); - assert.deepEqual(cors.get('origins'), []); - }); - - it('does not return an empty string (empty origin), when "specific origins" is set, but there are no domains on that list', () => { - const emptyOrigins = { origins: '' }; - cors.set(cors.parse(emptyOrigins)); - assert.deepEqual(cors.get('origins'), []); - }); - - it('allows valid domains', () => { - const urls = [ - 'http://something.com', - 'https://a.ca', - 'https://something.com:8000', - 'https://www.some-valid-domain.com:80', - 'http://localhost', - 'https://localhost', - 'http://192.168.1.113', - 'http://192.168.1.113:1337' - ]; - _.each(urls, function (url) { - assert.isTrue(CORS.validateCORSDomain(url)); - }); - }); - - it('fails on non http/https domains', () => { - const urls = [ - 'whoahnellythisaintright', - 'ftp://site.com' - ]; - _.each(urls, function (url) { - assert.isFalse(CORS.validateCORSDomain(url)); - }); - }); - - it('normalizes common cases, like accidentally added subfolders', () => { - assert.equal('https://foo.com', CORS.normalizeUrls('https://foo.com/blerg')); - assert.equal('https://192.168.1.113', CORS.normalizeUrls('https://192.168.1.113/blerg')); - assert.equal('https://foo.com:1337', CORS.normalizeUrls('https://foo.com:1337/blerg')); - assert.equal('https://foo.com', CORS.normalizeUrls('https://foo.com')); - }); - -}); diff --git a/app/addons/cors/__tests__/stores.test.js b/app/addons/cors/__tests__/stores.test.js deleted file mode 100644 index e9be117..0000000 --- a/app/addons/cors/__tests__/stores.test.js +++ /dev/null @@ -1,100 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the "License"); you may not -// use this file except in compliance with the License. You may obtain a copy of -// the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations under -// the License. -import testUtils from "../../../../test/mocha/testUtils"; -import Stores from "../stores"; -const assert = testUtils.assert; -const store = Stores.corsStore; - -describe('CORS store', () => { - - describe('isAllOrigins', () => { - - it('returns true for all origins', () => { - store._origins = ['*']; - - assert.ok(store.isAllOrigins()); - }); - - it('returns false for specific origins', () => { - store._origins = ['https://hello.com', 'http://another.com']; - - assert.notOk(store.isAllOrigins()); - }); - - it('returns false for empty array', () => { - store._origins = []; - - assert.notOk(store.isAllOrigins()); - }); - }); - - describe('addOrigin', () => { - - it('adds Origin to list', () => { - const origin = 'http://hello.com'; - store._origins = []; - store.addOrigin(origin); - - assert.ok(_.include(store.getOrigins(), origin)); - }); - - }); - - describe('originChange', () => { - - it('sets origins to * for true', () => { - store.originChange(true); - - assert.deepEqual(store.getOrigins(), ['*']); - }); - - it('sets origins to [] for true', () => { - store.originChange(false); - - assert.deepEqual(store.getOrigins(), []); - }); - - }); - - describe('deleteOrigin', () => { - - it('removes origin', () => { - store._origins = ['http://first.com', 'http://hello.com', 'http://second.com']; - store.deleteOrigin('http://hello.com'); - - assert.deepEqual(store.getOrigins(), ['http://first.com', 'http://second.com']); - - }); - - }); - - describe('update origin', () => { - - it('removes old origin', () => { - store._origins = ['http://first.com', 'http://hello.com', 'http://second.com']; - store.updateOrigin('http://hello123.com', 'http://hello.com'); - - assert.notOk(_.include(store.getOrigins(), 'http://hello.com')); - - }); - - it('adds new origin', () => { - store._origins = ['http://first.com', 'http://hello.com', 'http://second.com']; - store.updateOrigin('http://hello123.com', 'http://hello.com'); - - assert.ok(_.include(store.getOrigins(), 'http://hello123.com')); - - }); - - }); - -}); diff --git a/app/addons/cors/actions.js b/app/addons/cors/actions.js index 039cb67..3ba2e08 100644 --- a/app/addons/cors/actions.js +++ b/app/addons/cors/actions.js @@ -9,186 +9,102 @@ // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations under // the License. + import FauxtonAPI from "../../core/api"; import ActionTypes from "./actiontypes"; -import Resources from "./resources"; - -export default { - fetchAndEditCors: function (node) { - var cors = new Resources.Config({node: node}); - var httpd = new Resources.Httpd({node: node}); - - FauxtonAPI.when([cors.fetch(), httpd.fetch()]).then(function () { - this.editCors({ - origins: cors.get('origins'), - isEnabled: httpd.corsEnabled(), - node: node - }); - }.bind(this)); - }, +import * as CorsAPI from "./api"; - editCors: function (options) { - FauxtonAPI.dispatch({ - type: ActionTypes.EDIT_CORS, - options: options - }); - }, - - toggleEnableCors: function () { - FauxtonAPI.dispatch({ - type: ActionTypes.TOGGLE_ENABLE_CORS - }); - }, - - addOrigin: function (origin) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_ADD_ORIGIN, - origin: origin - }); - }, - - originChange: function (isAllOrigins) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_IS_ALL_ORIGINS, - isAllOrigins: isAllOrigins - }); - }, - - deleteOrigin: function (origin) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_DELETE_ORIGIN, - origin: origin - }); - }, +export const fetchAndLoadCORSOptions = (url, node) => (dispatch) => { + const fetchCors = CorsAPI.fetchCORSConfig(url); + const fetchHttp = CorsAPI.fetchHttpdConfig(url); - updateOrigin: function (updatedOrigin, originalOrigin) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_UPDATE_ORIGIN, - updatedOrigin: updatedOrigin, - originalOrigin: originalOrigin - }); - }, - - methodChange: function (httpMethod) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_METHOD_CHANGE, - httpMethod: httpMethod - }); - }, - - saveEnableCorsToHttpd: function (enableCors, node) { - var enableOption = new Resources.ConfigModel({ - section: 'httpd', - attribute: 'enable_cors', - value: enableCors.toString(), - node: node - }); - - return enableOption.save(); - }, - - saveCorsOrigins: function (origins, node) { - var allowOrigins = new Resources.ConfigModel({ - section: 'cors', - attribute: 'origins', - value: origins, + FauxtonAPI.Promise.join(fetchCors, fetchHttp, (corsConfig, httpdConfig) => { + const loadOptions = loadCORSOptions({ + origins: corsConfig.origins, + corsEnabled: httpdConfig.enable_cors === 'true', node: node }); - - return allowOrigins.save(); - }, - - saveCorsCredentials: function (node) { - var allowCredentials = new Resources.ConfigModel({ - section: 'cors', - attribute: 'credentials', - value: 'true', - node: node + dispatch(loadOptions); + }).catch((error) => { + FauxtonAPI.addNotification({ + msg: 'Could not load CORS settings. ' + errorReason(error), + type: 'error' }); + }); +}; - return allowCredentials.save(); - }, +export const showLoadingBars = () => { + return { + type: ActionTypes.CORS_SET_IS_LOADING, + isLoading: true + }; +}; - saveCorsHeaders: function (node) { - var corsHeaders = new Resources.ConfigModel({ - section: 'cors', - attribute: 'headers', - value: 'accept, authorization, content-type, origin, referer', - node: node - }); +export const hideLoadingBars = () => { + return { + type: ActionTypes.CORS_SET_IS_LOADING, + isLoading: false + }; +}; - return corsHeaders.save(); - }, +export const loadCORSOptions = (options) => { + return { + type: ActionTypes.EDIT_CORS, + options: options, + isLoading: false + }; +}; - saveCorsMethods: function (node) { - var corsMethods = new Resources.ConfigModel({ - section: 'cors', - attribute: 'methods', - value: 'GET, PUT, POST, HEAD, DELETE', - node: node - }); +export const showDomainDeleteConfirmation = (domain) => { + return { + type: ActionTypes.CORS_SHOW_DELETE_DOMAIN_MODAL, + domainToDelete: domain + }; +}; - return corsMethods.save(); - }, +export const hideDomainDeleteConfirmation = () => { + return { + type: ActionTypes.CORS_HIDE_DELETE_DOMAIN_MODAL + }; +}; - sanitizeOrigins: function (origins) { - if (_.isEmpty(origins)) { - return ''; - } +export const saveCors = (url, options) => (dispatch) => { + const promises = []; - return origins.join(','); - }, + promises.push(CorsAPI.updateEnableCorsToHttpd(url, options.node, options.corsEnabled)); + if (options.corsEnabled) { + promises.push(CorsAPI.updateCorsOrigins(url, options.node, sanitizeOrigins(options.origins))); + promises.push(CorsAPI.updateCorsCredentials(url, options.node)); + promises.push(CorsAPI.updateCorsHeaders(url, options.node)); + promises.push(CorsAPI.updateCorsMethods(url, options.node)); + } - toggleLoadingBarsToEnabled: function (state) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_SET_IS_LOADING, - isLoading: state + return FauxtonAPI.Promise.all(promises).then(() => { + FauxtonAPI.addNotification({ + msg: 'CORS settings updated.', + type: 'success', + clear: true }); - }, - - saveCors: function (options) { - this.toggleLoadingBarsToEnabled(true); - - var promises = []; - promises.push(this.saveEnableCorsToHttpd(options.enableCors, options.node)); - - if (options.enableCors) { - promises.push(this.saveCorsOrigins(this.sanitizeOrigins(options.origins), options.node)); - promises.push(this.saveCorsCredentials(options.node)); - promises.push(this.saveCorsHeaders(options.node)); - promises.push(this.saveCorsMethods(options.node)); - } - - FauxtonAPI.when(promises).then(function () { - FauxtonAPI.addNotification({ - msg: 'Cors settings updated.', - type: 'success', - clear: true - }); - - this.hideDeleteDomainModal(); // just in case it was already open - this.toggleLoadingBarsToEnabled(false); - - }.bind(this), function () { + dispatch(loadCORSOptions(options)); + }).catch((error) => { FauxtonAPI.addNotification({ - msg: 'Error! Could not save your CORS settings. Please try again.', + msg: 'Error! Could not save your CORS settings. Please try again. ' + errorReason(error), type: 'error', clear: true }); - this.toggleLoadingBarsToEnabled(false); - }.bind(this)); - }, - - showDeleteDomainModal: function (domain) { - FauxtonAPI.dispatch({ - type: ActionTypes.CORS_SHOW_DELETE_DOMAIN_MODAL, - options: { - domain: domain - } + dispatch(hideDomainDeleteConfirmation()); + dispatch(hideLoadingBars()); }); - }, +}; - hideDeleteDomainModal: function () { - FauxtonAPI.dispatch({ type: ActionTypes.CORS_HIDE_DELETE_DOMAIN_MODAL }); +const errorReason = (error) => { + return 'Reason: ' + ((error && error.message) || 'n/a'); +}; + +export const sanitizeOrigins = (origins) => { + if (_.isEmpty(origins)) { + return ''; } + + return origins.join(','); }; diff --git a/app/addons/cors/actiontypes.js b/app/addons/cors/actiontypes.js index 81ebd7f..63a58e4 100644 --- a/app/addons/cors/actiontypes.js +++ b/app/addons/cors/actiontypes.js @@ -9,14 +9,9 @@ // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations under // the License. + export default { - TOGGLE_ENABLE_CORS: 'TOGGLE_ENABLE_CORS', EDIT_CORS: 'EDIT_CORS', - CORS_ADD_ORIGIN: 'CORS_ADD_ORIGIN', - CORS_IS_ALL_ORIGINS: 'CORS_IS_ALL_ORIGINS', - CORS_DELETE_ORIGIN: 'CORS_DELETE_ORIGIN', - CORS_UPDATE_ORIGIN: 'CORS_UPDATE_ORIGIN', - CORS_METHOD_CHANGE: 'CORS_METHOD_CHANGE', CORS_SET_IS_LOADING: 'CORS_SET_IS_LOADING', CORS_SHOW_DELETE_DOMAIN_MODAL: 'CORS_SHOW_DELETE_DOMAIN_MODAL', CORS_HIDE_DELETE_DOMAIN_MODAL: 'CORS_HIDE_DELETE_DOMAIN_MODAL' diff --git a/app/addons/cors/api.js b/app/addons/cors/api.js new file mode 100644 index 0000000..82dda38 --- /dev/null +++ b/app/addons/cors/api.js @@ -0,0 +1,120 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +import 'whatwg-fetch'; + +export const fetchCORSConfig = (baseURL) => { + const configURL = baseURL + '/cors'; + return fetch(configURL, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'include', + method: 'GET' + }) + .then((res) => res.json()) + .then((json) => { + if (json.error) { + throw new Error(json.reason); + } + + const origins = !json.origins ? [] : json.origins.split(','); + return { + origins: origins, + methods: json.methods, + credentials: json.credentials, + headers: json.headers + }; + }); +}; + +export const fetchHttpdConfig = (baseURL) => { + const configURL = baseURL + '/httpd'; + return fetch(configURL, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'include', + method: 'GET' + }) + .then((res) => res.json()) + .then((json) => { + if (json.error) { + throw new Error(json.reason); + } + return json; + }); +}; + +export const updateEnableCorsToHttpd = (baseURL, node, enableCors) => { + if (!node) { + throw new Error('node not set'); + } + const configURL = baseURL + '/httpd/enable_cors'; + return fetch(configURL, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'include', + method: 'PUT', + body: JSON.stringify(enableCors.toString()) + }) + .then((res) => res.json()) + .then((json) => { + if (json.error) { + throw new Error(json.reason); + } + return json; + }); +}; + +export const updateCorsOrigins = (baseURL, node, origins) => { + return updateCorsProperty(baseURL, node, 'origins', origins); +}; + +export const updateCorsCredentials = (baseURL, node) => { + return updateCorsProperty(baseURL, node, 'credentials', 'true'); +}; + +export const updateCorsHeaders = (baseURL, node) => { + return updateCorsProperty(baseURL, node, 'headers', 'accept, authorization, content-type, origin, referer'); +}; + +export const updateCorsMethods = (baseURL, node) => { + return updateCorsProperty(baseURL, node, 'methods', 'GET, PUT, POST, HEAD, DELETE'); +}; + +const updateCorsProperty = (baseURL, node, propName, propValue) => { + if (!node) { + throw new Error('node not set'); + } + const configURL = baseURL + '/cors/' + propName; + return fetch(configURL, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + credentials: 'include', + method: 'PUT', + body: JSON.stringify(propValue) + }) + .then((res) => res.json()) + .then((json) => { + if (json.error) { + throw new Error(json.reason); + } + return json; + }); +}; diff --git a/app/addons/cors/base.js b/app/addons/cors/base.js index 6f8d400..f9c475f 100644 --- a/app/addons/cors/base.js +++ b/app/addons/cors/base.js @@ -12,8 +12,14 @@ import FauxtonAPI from "../../core/api"; import "./assets/less/cors.less"; -var CORS = FauxtonAPI.addon(); +import reducers from "./reducers"; + +const CORS = FauxtonAPI.addon(); CORS.initialize = function () {}; +FauxtonAPI.addReducers({ + cors: reducers +}); + export default CORS; diff --git a/app/addons/cors/components.js b/app/addons/cors/components.js index a986df4..71d0bbc 100644 --- a/app/addons/cors/components.js +++ b/app/addons/cors/components.js @@ -10,358 +10,28 @@ // License for the specific language governing permissions and limitations under // the License. -import app from "../../app"; -import FauxtonAPI from "../../core/api"; -import React from "react"; -import Stores from "./stores"; -import Resources from "./resources"; -import Actions from "./actions"; -import ReactComponents from "../components/react-components"; -import FauxtonComponents from "../fauxton/components"; -var LoadLines = ReactComponents.LoadLines; -var ConfirmationModal = FauxtonComponents.ConfirmationModal; +import CORSContainer from "./components/CORSContainer"; +import CORSScreen from "./components/CORSScreen"; +import OriginInput from "./components/OriginInput"; +import Origins from "./components/Origins"; +import OriginTable from "./components/OriginTable"; +import OriginRow from "./components/OriginRow"; -var corsStore = Stores.corsStore; - - -var validateOrigin = function (origin) { - if (!Resources.validateCORSDomain(origin)) { - FauxtonAPI.addNotification({ - msg: 'Please enter a valid domain, starting with http/https.', - type: 'error', - clear: true - }); - - return false; - } - - return true; +export default { + CORSContainer, + CORSScreen, + OriginInput, + Origins, + OriginTable, + OriginRow }; -var OriginRow = React.createClass({ - - getInitialState: function () { - return { - edit: false, - updatedOrigin: this.props.origin - }; - }, - - editOrigin: function (e) { - e.preventDefault(); - this.setState({ edit: !this.state.edit }); - }, - - updateOrigin: function (e) { - e.preventDefault(); - if (!validateOrigin(this.state.updatedOrigin)) { - return; - } - this.props.updateOrigin(this.state.updatedOrigin, this.props.origin); - this.setState({ edit: false }); - }, - - deleteOrigin: function (e) { - e.preventDefault(); - Actions.showDeleteDomainModal(this.props.origin); - }, - - onInputChange: function (event) { - this.setState({ updatedOrigin: event.target.value }); - }, - - onKeyUp: function (e) { - if (e.keyCode === 13) { //enter key - return this.updateOrigin(e); - } - }, - - createOriginDisplay: function () { - if (this.state.edit) { - return ( - <div className="input-append edit-domain-section"> - <input type="text" name="update_origin_domain" onChange={this.onInputChange} onKeyUp={this.onKeyUp} value={this.state.updatedOrigin} /> - <button onClick={this.updateOrigin} className="btn btn-primary update-origin"> Update </button> - </div> - ); - } - return <div className="js-url url-display">{this.props.origin}</div>; - }, - - render: function () { - var display = this.createOriginDisplay(); - return ( - <tr> - <td> - {display} - </td> - <td width="30"> - <span> - <a className="fonticon-pencil" onClick={this.editOrigin} title="Edit domain." /> - </span> - </td> - <td width="30"> - <span> - <a href="#" data-bypass="true" className="fonticon-trash" onClick={this.deleteOrigin} title="Delete domain." /> - </span> - </td> - </tr> - ); - } - -}); - -var OriginTable = React.createClass({ - - createRows: function () { - return _.map(this.props.origins, function (origin, i) { - return <OriginRow - updateOrigin={this.props.updateOrigin} - key={i} origin={origin} />; - }, this); - }, - - render: function () { - if (!this.props.origins) { - return null; - } - - if (!this.props.isVisible || this.props.origins.length === 0) { - return null; - } - - var origins = this.createRows(); - - return ( - <table id="origin-domain-table" className="table table-striped"> - <tbody> - {origins} - </tbody> - </table> - ); - } - -}); - -var OriginInput = React.createClass({ - getInitialState: function () { - return { - origin: '' - }; - }, - - onInputChange: function (e) { - this.setState({origin: e.target.value}); - }, - - addOrigin: function (event) { - event.preventDefault(); - if (!validateOrigin(this.state.origin)) { - return; - } - - var url = Resources.normalizeUrls(this.state.origin); - - this.props.addOrigin(url); - this.setState({origin: ''}); - }, - - onKeyUp: function (e) { - if (e.keyCode == 13) { //enter key - return this.addOrigin(e); - } - }, - render: function () { - if (!this.props.isVisible) { - return null; - } - return ( - <div id="origin-domains-container"> - <div className="origin-domains"> - <div className="input-append"> - <input type="text" name="new_origin_domain" onChange={this.onInputChange} onKeyUp={this.onKeyUp} value={this.state.origin} placeholder="https://example.com"/> - <button onClick={this.addOrigin} className="btn btn-secondary add-domain"><i className="icon fonticon-ok-circled"></i> Add Domain</button> - </div> - </div> - </div> - ); - } -}); -var Origins = React.createClass({ - onOriginChange: function (event) { - if (event.target.value === 'all' && this.props.isAllOrigins) { - return; // do nothing if all origins is already selected - } - if (event.target.value === 'selected' && !this.props.isAllOrigins) { - return; // do nothing if specific origins is already selected - } - this.props.originChange(event.target.value === 'all'); - }, - render: function () { - if (!this.props.corsEnabled) { - return null; - } - - return ( - <div> - <p><strong> Origin Domains </strong> </p> - <p>Databases will accept requests from these domains: </p> - <label className="radio"> - <input type="radio" checked={this.props.isAllOrigins} value="all" onChange={this.onOriginChange} name="all-domains"/> All domains ( * ) - </label> - <label className="radio"> - <input type="radio" checked={!this.props.isAllOrigins} value="selected" onChange={this.onOriginChange} name="selected-domains"/> Restrict to specific domains - </label> - </div> - ); - } -}); - -var CORSController = React.createClass({ - - getStoreState: function () { - return { - corsEnabled: corsStore.isEnabled(), - origins: corsStore.getOrigins(), - isAllOrigins: corsStore.isAllOrigins(), - configChanged: corsStore.hasConfigChanged(), - shouldSaveChange: corsStore.shouldSaveChange(), - node: corsStore.getNode(), - isLoading: corsStore.getIsLoading(), - deleteDomainModalVisible: corsStore.isDeleteDomainModalVisible(), - domainToDelete: corsStore.getDomainToDelete() - }; - }, - - getInitialState: function () { - return this.getStoreState(); - }, - - componentDidMount: function () { - corsStore.on('change', this.onChange, this); - }, - - componentWillUnmount: function () { - corsStore.off('change', this.onChange); - }, - - componentDidUpdate: function () { - if (this.state.shouldSaveChange) { - this.save(); - } - }, - - onChange: function () { - this.setState(this.getStoreState()); - }, - - enableCorsChange: function () { - if (this.state.corsEnabled && !_.isEmpty(this.state.origins)) { - var result = window.confirm(app.i18n.en_US['cors-disable-cors-prompt']); - if (!result) { return; } - } - - Actions.toggleEnableCors(); - }, - - save: function () { - Actions.saveCors({ - enableCors: this.state.corsEnabled, - origins: this.state.origins, - node: this.state.node - }); - }, - - originChange: function (isAllOrigins) { - if (isAllOrigins && !_.isEmpty(this.state.origins)) { - var result = window.confirm('Are you sure? Switching to all origin domains will overwrite your specific origin domains.'); - if (!result) { return; } - } - - Actions.originChange(isAllOrigins); - }, - - addOrigin: function (origin) { - Actions.addOrigin(origin); - }, - - updateOrigin: function (updatedOrigin, originalOrigin) { - Actions.updateOrigin(updatedOrigin, originalOrigin); - }, - - methodChange: function (httpMethod) { - Actions.methodChange(httpMethod); - }, - - deleteOrigin: function () { - Actions.deleteOrigin(this.state.domainToDelete); - }, - - render: function () { - var isVisible = _.all([this.state.corsEnabled, !this.state.isAllOrigins]); - - var originSettings = ( - <div id={this.state.corsEnabled ? 'collapsing-container' : ''}> - <Origins corsEnabled={this.state.corsEnabled} originChange={this.originChange} isAllOrigins={this.state.isAllOrigins}/> - <OriginTable updateOrigin={this.updateOrigin} isVisible={isVisible} origins={this.state.origins} /> - <OriginInput addOrigin={this.addOrigin} isVisible={isVisible} /> - </div> - ); - - if (this.state.isLoading) { - originSettings = (<LoadLines />); - } - var deleteMsg = <span>Are you sure you want to delete <code>{_.escape(this.state.domainToDelete)}</code>?</span>; - - return ( - <div className="cors-page flex-body"> - <header id="cors-header"> - <p>{app.i18n.en_US['cors-notice']}</p> - </header> - - <form id="corsForm" onSubmit={this.save}> - <div className="cors-enable"> - {this.state.corsEnabled ? 'Cors is currently enabled.' : 'Cors is currently disabled.'} - <br /> - <button - type="button" - className="enable-disable btn btn-secondary" - onClick={this.enableCorsChange} - disabled={this.state.isLoading ? 'disabled' : null} - > - {this.state.corsEnabled ? 'Disable CORS' : 'Enable CORS'} - </button> - </div> - {originSettings} - </form> - - <ConfirmationModal - title="Confirm Deletion" - visible={this.state.deleteDomainModalVisible} - text={deleteMsg} - buttonClass="btn-danger" - onClose={Actions.hideDeleteDomainModal} - onSubmit={this.deleteOrigin} - successButtonLabel="Delete Domain" /> - </div> - ); - } -}); - - -export default { - CORSController: CORSController, - OriginInput: OriginInput, - Origins: Origins, - OriginTable: OriginTable, - OriginRow: OriginRow -}; diff --git a/app/addons/cors/components/CORSContainer.js b/app/addons/cors/components/CORSContainer.js new file mode 100644 index 0000000..eece5ee --- /dev/null +++ b/app/addons/cors/components/CORSContainer.js @@ -0,0 +1,46 @@ +import { connect } from 'react-redux'; +import CORSScreen from './CORSScreen'; +import { + saveCors, showLoadingBars, fetchAndLoadCORSOptions, + showDomainDeleteConfirmation, hideDomainDeleteConfirmation +} from '../actions'; + +const mapStateToProps = ({ cors }) => { + return { + node: cors.node, + corsEnabled: cors.corsEnabled, + isAllOrigins: cors.isAllOrigins, + isLoading: cors.isLoading, + origins: cors.origins, + deleteDomainModalVisible: cors.deleteDomainModalVisible, + domainToDelete: cors.domainToDelete + }; +}; + +const mapDispatchToProps = (dispatch, ownProps) => { + return { + saveCORS: (options) => { + dispatch(showLoadingBars()); + dispatch(saveCors(ownProps.url, options)); + }, + + fetchAndLoadCORSOptions: () => { + dispatch(fetchAndLoadCORSOptions(ownProps.url, ownProps.node)); + }, + + showDeleteDomainConfirmation: (domain) => { + dispatch(showDomainDeleteConfirmation(domain)); + }, + + hideDeleteDomainConfirmation: () => { + dispatch(hideDomainDeleteConfirmation()); + } + }; +}; + +const CORSContainer = connect( + mapStateToProps, + mapDispatchToProps +)(CORSScreen); + +export default CORSContainer; diff --git a/app/addons/cors/components/CORSScreen.js b/app/addons/cors/components/CORSScreen.js new file mode 100644 index 0000000..e5d76b8 --- /dev/null +++ b/app/addons/cors/components/CORSScreen.js @@ -0,0 +1,150 @@ +import React, { Component } from "react"; +import app from "../../../app"; +import ReactComponents from "../../components/react-components"; +import FauxtonComponents from "../../fauxton/components"; +import Origins from "./Origins"; +import OriginInput from "./OriginInput"; +import OriginTable from "./OriginTable"; + +const LoadLines = ReactComponents.LoadLines; +const ConfirmationModal = FauxtonComponents.ConfirmationModal; + +export default class CORSScreen extends Component { + + constructor(props) { + super(props); + } + + componentDidMount() { + this.props.fetchAndLoadCORSOptions(); + } + + enableCorsChange() { + const { corsEnabled, origins, node } = this.props; + if (corsEnabled && !_.isEmpty(origins)) { + const result = window.confirm(app.i18n.en_US['cors-disable-cors-prompt']); + if (!result) { return; } + } + this.props.saveCORS({ + corsEnabled: !corsEnabled, + origins: origins, + node: node + }); + } + + save() { + this.props.saveCORS({ + corsEnabled: this.props.corsEnabled, + origins: this.props.origins, + node: this.props.node + }); + } + + originChange(isAllOrigins) { + if (isAllOrigins && !_.isEmpty(this.props.origins)) { + const result = window.confirm('Are you sure? Switching to all origin domains will overwrite your specific origin domains.'); + if (!result) { return; } + } + this.props.saveCORS({ + corsEnabled: this.props.corsEnabled, + origins: isAllOrigins ? ['*'] : [], + node: this.props.node + }); + } + + addOrigin(origin) { + this.props.saveCORS({ + corsEnabled: this.props.corsEnabled, + origins: this.props.origins.concat(origin), + node: this.props.node + }); + } + + updateOrigin(updatedOrigin, originalOrigin) { + const newOrigins = this.props.origins.slice(); + const index = _.indexOf(newOrigins, originalOrigin); + if (index === -1) { return; } + newOrigins[index] = updatedOrigin; + + this.props.saveCORS({ + corsEnabled: this.props.corsEnabled, + origins: newOrigins, + node: this.props.node + }); + } + + deleteOrigin() { + const { corsEnabled, origins, node, domainToDelete } = this.props; + const index = this.props.origins.indexOf(domainToDelete); + if (index === -1) { return; } + const newOrigins = [ + ...origins.slice(0, index), + ...origins.slice(index + 1) + ]; + + this.props.saveCORS({ + corsEnabled: corsEnabled, + origins: newOrigins, + node: node + }); + } + + render() { + const isVisible = [this.props.corsEnabled, !this.props.isAllOrigins].every((elem) => elem === true); + + let originSettings = ( + <div id={this.props.corsEnabled ? 'collapsing-container' : ''}> + <Origins + corsEnabled={this.props.corsEnabled} + originChange={this.originChange.bind(this)} + isAllOrigins={this.props.isAllOrigins} /> + <OriginTable + updateOrigin={this.updateOrigin.bind(this)} + deleteOrigin={this.props.showDeleteDomainConfirmation} + isVisible={isVisible} + origins={this.props.origins} /> + <OriginInput + addOrigin={this.addOrigin.bind(this)} + isVisible={isVisible} /> + </div> + ); + + if (this.props.isLoading) { + originSettings = (<LoadLines />); + } + const deleteMsg = <span>Are you sure you want to delete <code>{_.escape(this.props.domainToDelete)}</code>?</span>; + + return ( + <div className="cors-page flex-body"> + <header id="cors-header"> + <p>{app.i18n.en_US['cors-notice']}</p> + </header> + + <form id="corsForm" onSubmit={this.save.bind(this)}> + <div className="cors-enable"> + {this.props.corsEnabled ? 'CORS is currently enabled.' : 'CORS is currently disabled.'} + <br /> + <button + type="button" + className="enable-disable btn btn-secondary" + onClick={this.enableCorsChange.bind(this)} + disabled={this.props.isLoading ? 'disabled' : null} + > + {this.props.corsEnabled ? 'Disable CORS' : 'Enable CORS'} + </button> + </div> + {originSettings} + </form> + + <ConfirmationModal + title="Confirm Deletion" + visible={this.props.deleteDomainModalVisible} + text={deleteMsg} + buttonClass="btn-danger" + onClose={this.props.hideDeleteDomainConfirmation} + onSubmit={this.deleteOrigin.bind(this)} + successButtonLabel="Delete Domain" /> + </div> + ); + } +} diff --git a/app/addons/cors/components/OriginInput.js b/app/addons/cors/components/OriginInput.js new file mode 100644 index 0000000..bcd8720 --- /dev/null +++ b/app/addons/cors/components/OriginInput.js @@ -0,0 +1,60 @@ +import React, { Component } from "react"; +import { validateDomain, normalizeUrls } from "../helpers"; + +export default class OriginInput extends Component { + + constructor(props) { + super(props); + this.state = { + origin: '' + }; + } + + onInputChange(e) { + this.setState({ origin: e.target.value }); + } + + addOrigin(event) { + event.preventDefault(); + if (!validateDomain(this.state.origin)) { + return false; + } + + const url = normalizeUrls(this.state.origin); + + this.props.addOrigin(url); + this.setState({ origin: '' }); + } + + onKeyUp(e) { + if (e.keyCode == 13) { //enter key + return this.addOrigin(e); + } + } + + render() { + if (!this.props.isVisible) { + return null; + } + + return ( + <div id="origin-domains-container"> + <div className="origin-domains"> + <div className="input-append"> + <input type="text" name="new_origin_domain" placeholder="https://example.com" + onChange={this.onInputChange.bind(this)} value={this.state.origin} /> + <button onClick={this.addOrigin.bind(this)} className="btn btn-secondary add-domain"> + <i className="icon fonticon-ok-circled"></i> Add Domain + </button> + </div> + </div> + </div> + ); + } + +}; + +OriginInput.propTypes = { + isVisible: React.PropTypes.bool.isRequired, + addOrigin: React.PropTypes.func.isRequired +}; diff --git a/app/addons/cors/components/OriginRow.js b/app/addons/cors/components/OriginRow.js new file mode 100644 index 0000000..cbc73eb --- /dev/null +++ b/app/addons/cors/components/OriginRow.js @@ -0,0 +1,86 @@ +import React, { Component } from "react"; +import { validateDomain } from "../helpers"; + + +export default class OriginRow extends Component { + + constructor (props) { + super(props); + this.state = { + edit: false, + updatedOrigin: this.props.origin + }; + } + + editOrigin(e) { + e.preventDefault(); + this.setState({ edit: !this.state.edit }); + } + + updateOrigin (e) { + e.preventDefault(); + if (!validateDomain(this.state.updatedOrigin)) { + return; + } + + if (this.state.updatedOrigin && this.props.origin !== this.state.updatedOrigin) { + this.props.updateOrigin(this.state.updatedOrigin, this.props.origin); + } + this.setState({ edit: false }); + } + + deleteOrigin (e) { + e.preventDefault(); + this.props.deleteOrigin(this.props.origin); + } + + onInputChange (event) { + this.setState({ updatedOrigin: event.target.value }); + } + + onKeyUp (e) { + if (e.keyCode === 13) { //enter key + return this.updateOrigin(e); + } + } + + createOriginDisplay () { + if (this.state.edit) { + return ( + <div className="input-append edit-domain-section"> + <input type="text" name="update_origin_domain" onChange={ this.onInputChange.bind(this) } onKeyUp={ this.onKeyUp.bind(this) } value={this.state.updatedOrigin} /> + <button onClick={ this.updateOrigin.bind(this) } className="btn btn-primary update-origin"> Update </button> + </div> + ); + } + return <div className="js-url url-display">{this.props.origin}</div>; + } + + render () { + const display = this.createOriginDisplay(); + return ( + <tr> + <td> + {display} + </td> + <td width="30"> + <span> + <a className="fonticon-pencil" onClick={ this.editOrigin.bind(this) } title="Edit domain." /> + </span> + </td> + <td width="30"> + <span> + <a href="#" data-bypass="true" className="fonticon-trash" onClick={ this.deleteOrigin.bind(this) } title="Delete domain." /> + </span> + </td> + </tr> + ); + } + +}; + +OriginRow.propTypes = { + origin: React.PropTypes.string.isRequired, + updateOrigin: React.PropTypes.func.isRequired, + deleteOrigin: React.PropTypes.func.isRequired +}; diff --git a/app/addons/cors/components/OriginTable.js b/app/addons/cors/components/OriginTable.js new file mode 100644 index 0000000..41e14fa --- /dev/null +++ b/app/addons/cors/components/OriginTable.js @@ -0,0 +1,48 @@ +import React, { Component } from "react"; +import OriginRow from "./OriginRow"; + +export default class OriginTable extends Component { + + constructor (props) { + super(props); + } + + createRows () { + return this.props.origins.map((origin, i) => { + return <OriginRow + updateOrigin={this.props.updateOrigin} + deleteOrigin={this.props.deleteOrigin} + key={i} origin={origin} />; + }); + } + + render () { + const {origins, isVisible} = this.props; + + if (!origins) { + return null; + } + + if (!isVisible || origins.length === 0) { + return null; + } + + const originRows = this.createRows(); + + return ( + <table id="origin-domain-table" className="table table-striped"> + <tbody> + {originRows} + </tbody> + </table> + ); + } + +}; + +OriginTable.propTypes = { + isVisible: React.PropTypes.bool.isRequired, + origins: React.PropTypes.arrayOf(React.PropTypes.string), + updateOrigin: React.PropTypes.func.isRequired, + deleteOrigin: React.PropTypes.func.isRequired +}; diff --git a/app/addons/cors/components/Origins.js b/app/addons/cors/components/Origins.js new file mode 100644 index 0000000..103f8b5 --- /dev/null +++ b/app/addons/cors/components/Origins.js @@ -0,0 +1,45 @@ +import React, { Component } from "react"; + +export default class Origins extends Component { + + constructor (props) { + super(props); + } + + onOriginChange (event) { + if (event.target.value === 'all' && this.props.isAllOrigins) { + return; // do nothing if all origins is already selected + } + if (event.target.value === 'selected' && !this.props.isAllOrigins) { + return; // do nothing if specific origins is already selected + } + + this.props.originChange(event.target.value === 'all'); + } + + render () { + + if (!this.props.corsEnabled) { + return null; + } + + return ( + <div> + <p><strong> Origin Domains </strong> </p> + <p>Databases will accept requests from these domains: </p> + <label className="radio"> + <input type="radio" checked={this.props.isAllOrigins} value="all" onChange={ this.onOriginChange.bind(this) } name="all-domains"/> All domains ( * ) + </label> + <label className="radio"> + <input type="radio" checked={!this.props.isAllOrigins} value="selected" onChange={ this.onOriginChange.bind(this) } name="selected-domains"/> Restrict to specific domains + </label> + </div> + ); + } +}; + +Origins.propTypes = { + corsEnabled: React.PropTypes.bool, + isAllOrigins: React.PropTypes.bool, + originChange: React.PropTypes.func.isRequired +}; diff --git a/app/addons/cors/base.js b/app/addons/cors/helpers.js similarity index 50% copy from app/addons/cors/base.js copy to app/addons/cors/helpers.js index 6f8d400..3ecf4f0 100644 --- a/app/addons/cors/base.js +++ b/app/addons/cors/helpers.js @@ -11,9 +11,34 @@ // the License. import FauxtonAPI from "../../core/api"; -import "./assets/less/cors.less"; -var CORS = FauxtonAPI.addon(); -CORS.initialize = function () {}; +export const validateDomain = (domain) => { + if (!validateCORSDomain(domain)) { + FauxtonAPI.addNotification({ + msg: 'Please enter a valid domain, starting with http/https.', + type: 'error', + clear: true + }); + return false; + } + return true; +}; + +export const validateCORSDomain = (domain) => { + return (/^https?:\/\/(.+)(:\d{2,5})?$/).test(domain); +}; + +export const normalizeUrls = (url) => { + const el = document.createElement('a'); + el.href = url; + + if (/:/.test(url)) { + return el.protocol + '//' + el.host; + } + + return el.protocol + '//' + el.hostname; +}; + + + -export default CORS; diff --git a/app/addons/cors/reducers.js b/app/addons/cors/reducers.js new file mode 100644 index 0000000..9b8d3f2 --- /dev/null +++ b/app/addons/cors/reducers.js @@ -0,0 +1,67 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +import ActionTypes from "./actiontypes"; + +const initialState = { + corsEnabled: false, + origins: [], + isAllOrigins: false, + configChanged: false, + shouldSaveChange: false, + node: '', + isLoading: true, + deleteDomainModalVisible: false, + domainToDelete: '' +}; + +export default function cors (state = initialState, action) { + switch (action.type) { + + case ActionTypes.EDIT_CORS: + const corsOptions = action.options; + return { + ...state, + isLoading: false, + node: corsOptions.node, + corsEnabled: corsOptions.corsEnabled, + isAllOrigins: _.include(corsOptions.origins, '*'), + origins: corsOptions.origins, + deleteDomainModalVisible: false, + domainToDelete: '' + }; + + case ActionTypes.CORS_SHOW_DELETE_DOMAIN_MODAL: + return { + ...state, + deleteDomainModalVisible: true, + domainToDelete: action.domainToDelete + }; + + case ActionTypes.CORS_HIDE_DELETE_DOMAIN_MODAL: + return { + ...state, + deleteDomainModalVisible: false, + domainToDelete: '' + }; + + case ActionTypes.CORS_SET_IS_LOADING: + return { + ...state, + isLoading: action.isLoading + }; + + default: + return state; + } +}; + diff --git a/app/addons/cors/resources.js b/app/addons/cors/resources.js deleted file mode 100644 index 6c9a1c3..0000000 --- a/app/addons/cors/resources.js +++ /dev/null @@ -1,110 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the 'License'); you may not -// use this file except in compliance with the License. You may obtain a copy of -// the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations under -// the License. - -import app from "../../app"; -import FauxtonAPI from "../../core/api"; -var CORS = FauxtonAPI.addon(); - - -CORS.Config = FauxtonAPI.Model.extend({ - url: function () { - if (!this.get('node')) { - throw new Error('node not set'); - } - - return window.location.origin + '/_node/' + this.get('node') + '/_config/cors'; - }, - - parse: function (resp) { - var origins = !resp.origins ? [] : resp.origins.split(','); - - return { - origins: origins, - methods: resp.methods, - credentials: resp.credentials, - headers: resp.headers - }; - } -}); - -CORS.Httpd = FauxtonAPI.Model.extend({ - url: function () { - if (!this.get('node')) { - throw new Error('node not set'); - } - - return window.location.origin + '/_node/' + this.get('node') + '/_config/httpd'; - }, - - corsEnabled: function () { - var enabledCors = this.get('enable_cors'); - - if (_.isUndefined(enabledCors)) { - return false; - } - - return enabledCors === 'true'; - } - -}); - -CORS.ConfigModel = Backbone.Model.extend({ - documentation: 'cors', - - url: function () { - if (!this.get('node')) { - throw new Error('node not set'); - } - - return app.host + '/_node/' + this.get('node') + '/_config/' + - encodeURIComponent(this.get('section')) + '/' + encodeURIComponent(this.get('attribute')); - }, - - isNew: function () { return false; }, - - sync: function (method, model) { - - var params = { - url: model.url(), - contentType: 'application/json', - dataType: 'json', - data: JSON.stringify(model.get('value')) - }; - - if (method === 'delete') { - params.type = 'DELETE'; - } else { - params.type = 'PUT'; - } - - return $.ajax(params); - } - -}); - -// simple helper function to validate the user entered a valid domain starting with http(s) -CORS.validateCORSDomain = function (str) { - return (/^https?:\/\/(.*)(:\d{2,5})?$/).test(str); -}; - -CORS.normalizeUrls = function (url) { - var el = document.createElement('a'); - el.href = url; - - if (/:/.test(url)) { - return el.protocol + '//' + el.host; - } - - return el.protocol + '//' + el.hostname; -}; - -export default CORS; diff --git a/app/addons/cors/stores.js b/app/addons/cors/stores.js deleted file mode 100644 index e8fdd62..0000000 --- a/app/addons/cors/stores.js +++ /dev/null @@ -1,190 +0,0 @@ -// Licensed under the Apache License, Version 2.0 (the "License"); you may not -// use this file except in compliance with the License. You may obtain a copy of -// the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations under -// the License. - -import FauxtonAPI from "../../core/api"; -import ActionTypes from "./actiontypes"; - -var CorsStore = FauxtonAPI.Store.extend({ - - initialize: function () { - this.reset(); - }, - - reset: function () { - this._deleteDomainModalVisible = false; - this._domainToDelete = ''; - }, - - editCors: function (options) { - this._isEnabled = options.isEnabled; - this._origins = options.origins; - this._configChanged = false; - this._shouldSaveChange = false; - this._node = options.node; - this._isLoading = false; - }, - - shouldSaveChange: function () { - return this._shouldSaveChange; - }, - - hasConfigChanged: function () { - return this._configChanged; - }, - - setConfigChanged: function () { - this._configChanged = true; - }, - - setConfigSaved: function () { - this._configChanged = false; - }, - - setIsLoading: function (state) { - this._isLoading = state; - this._shouldSaveChange = false; - }, - - getIsLoading: function () { - return this._isLoading; - }, - - isEnabled: function () { - return this._isEnabled; - }, - - addOrigin: function (origin) { - this._origins.push(origin); - }, - - deleteOrigin: function (origin) { - var index = _.indexOf(this._origins, origin); - - if (index === -1) { return; } - - this._origins.splice(index, 1); - }, - - originChange: function (isAllOrigins) { - if (isAllOrigins) { - this._origins = ['*']; - return; - } - - this._origins = []; - }, - - getOrigins: function () { - return this._origins; - }, - - getNode: function () { - return this._node; - }, - - isAllOrigins: function () { - var origins = this.getOrigins(); - return _.include(origins, '*'); - }, - - toggleEnableCors: function () { - this._isEnabled = !this._isEnabled; - }, - - updateOrigin: function (updatedOrigin, originalOrigin) { - this.deleteOrigin(originalOrigin); - this.addOrigin(updatedOrigin); - }, - - showDeleteDomainModal: function (domain) { - this._domainToDelete = domain; - this._deleteDomainModalVisible = true; - this._shouldSaveChange = false; - }, - - hideDeleteDomainModal: function () { - this._deleteDomainModalVisible = false; - this._shouldSaveChange = false; - }, - - isDeleteDomainModalVisible: function () { - return this._deleteDomainModalVisible; - }, - - getDomainToDelete: function () { - return this._domainToDelete; - }, - - dispatch: function (action) { - // it should save after any change is triggered except for EDIT_CORS which is just to update - // cors after the first change - this._shouldSaveChange = true; - - switch (action.type) { - case ActionTypes.EDIT_CORS: - this.editCors(action.options); - break; - - case ActionTypes.TOGGLE_ENABLE_CORS: - this.toggleEnableCors(); - this.setConfigChanged(); - break; - - case ActionTypes.CORS_ADD_ORIGIN: - this.addOrigin(action.origin); - this.setConfigChanged(); - break; - - case ActionTypes.CORS_IS_ALL_ORIGINS: - this.originChange(action.isAllOrigins); - this.setConfigChanged(); - break; - - case ActionTypes.CORS_DELETE_ORIGIN: - this.deleteOrigin(action.origin); - this.setConfigChanged(); - break; - - case ActionTypes.CORS_UPDATE_ORIGIN: - this.updateOrigin(action.updatedOrigin, action.originalOrigin); - this.setConfigChanged(); - break; - - case ActionTypes.CORS_SET_IS_LOADING: - this.setIsLoading(action.isLoading); - break; - - case ActionTypes.CORS_SHOW_DELETE_DOMAIN_MODAL: - this.showDeleteDomainModal(action.options.domain); - break; - - case ActionTypes.CORS_HIDE_DELETE_DOMAIN_MODAL: - this.hideDeleteDomainModal(); - break; - - default: - return; - } - - this.triggerChange(); - } - -}); - - -var corsStore = new CorsStore(); - -corsStore.dispatchToken = FauxtonAPI.dispatcher.register(corsStore.dispatch.bind(corsStore)); - -export default { - corsStore: corsStore -}; -- To stop receiving notification emails like this one, please contact ['"commits@couchdb.apache.org" <commits@couchdb.apache.org>'].