From 9f31f035781761c70f67147dc8c6a63734967bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BAcio=20Rubens?= <4539235+luciorubeens@users.noreply.github.com> Date: Wed, 30 Oct 2019 08:56:31 -0300 Subject: [PATCH] refactor: plugin sandbox composition (#1507) * wip * refactor: move component sandbox methods to specific folder * feat: add PROFILE_ALL permission * feat: add PROFILE_CURRENT permission * feat: add PEER_CURRENT permission * feat: add STORAGE permission * feat: add AUDIO permission * feat: add EVENTS permission * feat: add ALERTS permission * fix: component validation * refactor: move THEMES permission to setup * test: add component validation specs * refactor: timers and websocket sandbox specs * test: add audio sandbox specs * test: add http sandbox specs * test: add profile-all sandbox specs * test: add alerts and events sandbox specs * test: add font awesome and messaging sandbox specs * test: fix profile all sandbox * test: add profile current and webframe sandbox specs * 'test: add ui components and storage sandbox specs * test: peer current and route sandbox specs * test: add avatars setup specs * test: add wallet tabs setup specs * test: register setup specs * fix: ui components sandbox * test: add menu items setup specs * test: add routes setup specs * test: add themes setup specs * test: add prepare context specs * test: add define context specs * fix: relative imports * refactor: create safe component * test: add components setup specs * feat: global components * fix: compile template * fix: don't load a disabled plugin * test: add plugin-manager specs * fix: lint * fix: remove document object from component sandbox * fix: props * fix: sync changes on props * refactor: block ref methods * test: blocked ref methods * fix: component name output in log * style: validate component method * fix: global components * fix: computed data * test: add methods * fix: add root path to sandbox external modules * fix: pass arguments to plugin methods * fix: resolve method * fix: regex check for html events --- .../component/compile-template.js | 6 + .../unit/services/plugin-manager.spec.js | 194 ++++ .../component/create-component.spec.js | 290 ++++++ .../component/get-context.spec.js | 71 ++ .../plugin-manager/component/validate.spec.js | 69 ++ .../plugin-component-sandbox.spec.js | 118 +++ .../sandbox/alerts-sandbox.spec.js | 21 + .../sandbox/audio-sandbox.spec.js | 13 + .../sandbox/events-sandbox.spec.js | 14 + .../sandbox/font-awesome-sandbox.spec.js | 11 + .../sandbox/http-sandbox.spec.js | 52 ++ .../sandbox/messaging-sandbox.spec.js | 40 + .../sandbox/peer-current-sandbox.spec.js | 15 + .../sandbox/profile-all-sandbox.spec.js | 32 + .../sandbox/profile-current-sandbox.spec.js | 29 + .../sandbox/route-sandbox.spec.js | 13 + .../sandbox/storage-sandbox.spec.js | 60 ++ .../sandbox/timers-sandbox.spec.js | 100 ++ .../websocket-sandbox.spec.js} | 70 +- .../setup/avatars-setup.spec.js | 44 + .../setup/components-setup.spec.js | 54 ++ .../setup/menu-items-setup.spec.js | 50 + .../setup/register-setup.spec.js | 14 + .../plugin-manager/setup/routes-setup.spec.js | 70 ++ .../plugin-manager/setup/themes-setup.spec.js | 44 + .../setup/ui-components-setup.spec.js | 17 + .../setup/wallet-tabs-setup.spec.js | 44 + .../setup/webframe-setup.spec.js | 17 + .../services/plugin-manager/timers.spec.js | 103 --- src/renderer/services/plugin-manager.js | 853 ++---------------- .../component/compile-template.js | 20 + .../component/create-component.js | 85 ++ .../plugin-manager/component/get-context.js | 115 +++ .../plugin-manager/component/hooks.js | 9 + .../plugin-manager/component/validate.js | 85 ++ .../plugin-manager/font-awesome-sandbox.js | 7 - .../plugin-component-sandbox.js | 87 ++ .../plugin-manager/plugin-configuration.js | 38 + .../plugin-manager/plugin-permission.js | 30 + .../services/plugin-manager/plugin-sandbox.js | 119 +++ .../services/plugin-manager/plugin-setup.js | 89 ++ .../services/plugin-manager/plugin.js | 44 + .../plugin-manager/sandbox/alerts-sandbox.js | 10 + .../plugin-manager/sandbox/audio-sandbox.js | 5 + .../plugin-manager/sandbox/events-sandbox.js | 5 + .../sandbox/font-awesome-sandbox.js | 7 + .../{http.js => sandbox/http-sandbox.js} | 8 +- .../sandbox/messaging-sandbox.js | 37 + .../sandbox/peer-current-sandbox.js | 15 + .../sandbox/profile-all-sandbox.js | 9 + .../sandbox/profile-current-sandbox.js | 15 + .../plugin-manager/sandbox/route-sandbox.js | 17 + .../plugin-manager/sandbox/storage-sandbox.js | 30 + .../plugin-manager/sandbox/timers-sandbox.js | 66 ++ .../websocket-sandbox.js} | 8 +- .../plugin-manager/setup/avatars-setup.js | 31 + .../plugin-manager/setup/components-setup.js | 41 + .../setup/font-awesome-setup.js | 7 + .../plugin-manager/setup/menu-items-setup.js | 30 + .../plugin-manager/setup/register-setup.js | 7 + .../plugin-manager/setup/routes-setup.js | 32 + .../plugin-manager/setup/themes-setup.js | 38 + .../setup/ui-components-setup.js | 26 + .../plugin-manager/setup/wallet-tabs-setup.js | 26 + .../plugin-manager/setup/webframe-setup.js | 7 + .../plugin-manager/utils/get-all-routes.js | 4 + .../plugin-manager/utils/normalize-json.js | 3 + .../utils/validate-plugin-path.js | 16 + .../plugin-manager/wallet-components.js | 30 - src/renderer/store/modules/plugin.js | 6 +- 70 files changed, 2827 insertions(+), 965 deletions(-) create mode 100644 __tests__/unit/__mocks__/@/services/plugin-manager/component/compile-template.js create mode 100644 __tests__/unit/services/plugin-manager.spec.js create mode 100644 __tests__/unit/services/plugin-manager/component/create-component.spec.js create mode 100644 __tests__/unit/services/plugin-manager/component/get-context.spec.js create mode 100644 __tests__/unit/services/plugin-manager/component/validate.spec.js create mode 100644 __tests__/unit/services/plugin-manager/plugin-component-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/alerts-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/audio-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/events-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/font-awesome-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/http-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/messaging-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/peer-current-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/profile-all-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/profile-current-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/route-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/storage-sandbox.spec.js create mode 100644 __tests__/unit/services/plugin-manager/sandbox/timers-sandbox.spec.js rename __tests__/unit/services/plugin-manager/{websocket.spec.js => sandbox/websocket-sandbox.spec.js} (67%) create mode 100644 __tests__/unit/services/plugin-manager/setup/avatars-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/components-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/menu-items-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/register-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/routes-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/themes-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/ui-components-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/wallet-tabs-setup.spec.js create mode 100644 __tests__/unit/services/plugin-manager/setup/webframe-setup.spec.js delete mode 100644 __tests__/unit/services/plugin-manager/timers.spec.js create mode 100644 src/renderer/services/plugin-manager/component/compile-template.js create mode 100644 src/renderer/services/plugin-manager/component/create-component.js create mode 100644 src/renderer/services/plugin-manager/component/get-context.js create mode 100644 src/renderer/services/plugin-manager/component/hooks.js create mode 100644 src/renderer/services/plugin-manager/component/validate.js delete mode 100644 src/renderer/services/plugin-manager/font-awesome-sandbox.js create mode 100644 src/renderer/services/plugin-manager/plugin-component-sandbox.js create mode 100644 src/renderer/services/plugin-manager/plugin-configuration.js create mode 100644 src/renderer/services/plugin-manager/plugin-permission.js create mode 100644 src/renderer/services/plugin-manager/plugin-sandbox.js create mode 100644 src/renderer/services/plugin-manager/plugin-setup.js create mode 100644 src/renderer/services/plugin-manager/plugin.js create mode 100644 src/renderer/services/plugin-manager/sandbox/alerts-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/audio-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/events-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/font-awesome-sandbox.js rename src/renderer/services/plugin-manager/{http.js => sandbox/http-sandbox.js} (80%) create mode 100644 src/renderer/services/plugin-manager/sandbox/messaging-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/peer-current-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/profile-all-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/profile-current-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/route-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/storage-sandbox.js create mode 100644 src/renderer/services/plugin-manager/sandbox/timers-sandbox.js rename src/renderer/services/plugin-manager/{websocket.js => sandbox/websocket-sandbox.js} (91%) create mode 100644 src/renderer/services/plugin-manager/setup/avatars-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/components-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/font-awesome-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/menu-items-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/register-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/routes-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/themes-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/ui-components-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/wallet-tabs-setup.js create mode 100644 src/renderer/services/plugin-manager/setup/webframe-setup.js create mode 100644 src/renderer/services/plugin-manager/utils/get-all-routes.js create mode 100644 src/renderer/services/plugin-manager/utils/normalize-json.js create mode 100644 src/renderer/services/plugin-manager/utils/validate-plugin-path.js delete mode 100644 src/renderer/services/plugin-manager/wallet-components.js diff --git a/__tests__/unit/__mocks__/@/services/plugin-manager/component/compile-template.js b/__tests__/unit/__mocks__/@/services/plugin-manager/component/compile-template.js new file mode 100644 index 0000000000..a633c9c0d4 --- /dev/null +++ b/__tests__/unit/__mocks__/@/services/plugin-manager/component/compile-template.js @@ -0,0 +1,6 @@ +export default jest.fn().mockImplementation(() => ({ + compileTemplate: jest.fn((vm, template) => { + const { compileToFunctions } = require('vue-template-compiler') + return compileToFunctions(template) + }) +})) diff --git a/__tests__/unit/services/plugin-manager.spec.js b/__tests__/unit/services/plugin-manager.spec.js new file mode 100644 index 0000000000..c70f90c437 --- /dev/null +++ b/__tests__/unit/services/plugin-manager.spec.js @@ -0,0 +1,194 @@ +import * as fs from 'fs' +import * as fsExtra from 'fs-extra' +import { createLocalVue } from '@vue/test-utils' +import { PluginManager } from '@/services/plugin-manager' +import { PluginSandbox } from '@/services/plugin-manager/plugin-sandbox' +import { PluginSetup } from '@/services/plugin-manager/plugin-setup' + +jest.mock('@/services/plugin-manager/plugin-sandbox.js') +jest.mock('@/services/plugin-manager/plugin-setup.js') + +jest.mock('fs-extra', () => ({ + ensureDirSync: jest.fn(), + readdirSync: jest.fn(() => []), + lstatSync: jest.fn(() => ({ + isDirectory: jest.fn(() => true) + })) +})) + +jest.mock('@/services/plugin-manager/utils/validate-plugin-path.js', () => ({ + validatePluginPath: jest.fn() +})) + +const mockDispatch = jest.fn() +const mockSandboxInstall = jest.fn() +const mockSandboxSetup = jest.fn() + +PluginSandbox.mockImplementation(() => ({ + install: mockSandboxInstall +})) + +PluginSetup.mockImplementation(() => ({ + install: mockSandboxSetup +})) + +const localVue = createLocalVue() + +const pkg = { + name: 'plugin-test', + description: 'Test', + title: 'Plugin Test', + version: '0.0.1' +} + +const app = { + $store: { + dispatch: mockDispatch, + getters: { + 'plugin/isEnabled': jest.fn((pluginId) => pluginId === 'plugin-test'), + 'profile/byId': jest.fn(() => {}) + } + } +} + +let pluginManager + +beforeEach(() => { + mockDispatch.mockReset() + pluginManager = new PluginManager() + pluginManager.setVue(localVue) +}) + +describe('Plugin Manager', () => { + it('should load plugins on init', async () => { + await pluginManager.init(app) + expect(app.$store.dispatch).toHaveBeenNthCalledWith(1, 'plugin/init') + expect(app.$store.dispatch).toHaveBeenNthCalledWith(2, 'plugin/loadPluginsForProfiles') + }) + + describe('Fetch plugins', () => { + it('should read plugins from path', async () => { + jest.spyOn(fsExtra, 'readdirSync').mockReturnValue(['plugin-1']) + jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify(pkg)) + + await pluginManager.init(app) + + expect(app.$store.dispatch).toHaveBeenCalledWith('plugin/setAvailable', + expect.objectContaining({ + config: expect.any(Object), + fullPath: expect.any(String) + }) + ) + }) + }) + + describe('Enable plugin', () => { + it('should throw not initiated error', async () => { + expect.assertions(1) + try { + await pluginManager.enablePlugin('plugin-1', 'p-1') + } catch (e) { + expect(e.message).toBe('Plugin Manager not initiated') + } + }) + + it('should throw not found error', async () => { + expect.assertions(1) + await pluginManager.init(app) + try { + await pluginManager.enablePlugin('plugin-not-loaded', 'p-1') + } catch (e) { + expect(e.message).toBe('Plugin not found') + } + }) + + it('should throw not enabled error', async () => { + expect.assertions(2) + await pluginManager.init(app) + pluginManager.plugins = { + 'plugin-not-enabled': { + config: { + id: '1' + } + } + } + try { + await pluginManager.enablePlugin('plugin-not-enabled', 'p-1') + } catch (e) { + expect(e.message).toBe('Plugin is not enabled') + expect(app.$store.getters['plugin/isEnabled']).toHaveBeenCalled() + } + }) + + it('should enable', async () => { + await pluginManager.init(app) + + pluginManager.plugins = { + [pkg.name]: { + config: { + id: pkg.name + }, + fullPath: './test' + } + } + + await pluginManager.enablePlugin(pkg.name, 'p-1') + expect(mockDispatch).toHaveBeenCalledWith('plugin/setLoaded', expect.any(Object)) + expect(mockSandboxInstall).toHaveBeenCalled() + expect(mockSandboxSetup).toHaveBeenCalled() + }) + }) + + describe('Disable plugin', () => { + it('should throw not initiated error', async () => { + expect.assertions(1) + try { + await pluginManager.disablePlugin('plugin-1', 'p-1') + } catch (e) { + expect(e.message).toBe('Plugin Manager not initiated') + } + }) + + it('should throw not found error', async () => { + expect.assertions(1) + await pluginManager.init(app) + try { + await pluginManager.disablePlugin('plugin-not-loaded', 'p-1') + } catch (e) { + expect(e.message).toBe('Plugin `plugin-not-loaded` not found') + } + }) + + it('should disable', async () => { + await pluginManager.init(app) + pluginManager.plugins = { + [pkg.name]: { + config: { + id: pkg.name, + permissions: [] + } + } + } + + await pluginManager.disablePlugin(pkg.name, 'p-1') + expect(mockDispatch).toHaveBeenCalledWith('plugin/deleteLoaded', pkg.name) + }) + + it('should unload theme', async () => { + await pluginManager.init(app) + pluginManager.plugins = { + [pkg.name]: { + config: { + id: pkg.name, + permissions: ['THEMES'] + } + } + } + + await pluginManager.disablePlugin(pkg.name, 'p-1') + expect(mockDispatch).toHaveBeenCalledWith('plugin/deleteLoaded', pkg.name) + expect(mockDispatch).toHaveBeenCalledWith('session/setTheme', expect.any(String)) + expect(mockDispatch).toHaveBeenCalledWith('profile/update', expect.any(Object)) + }) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/component/create-component.spec.js b/__tests__/unit/services/plugin-manager/component/create-component.spec.js new file mode 100644 index 0000000000..25a3d25892 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/component/create-component.spec.js @@ -0,0 +1,290 @@ +import { createLocalVue, mount } from '@vue/test-utils' +import { createSafeComponent } from '@/services/plugin-manager/component/create-component' + +const localVue = createLocalVue() + +describe('Create Component', () => { + it('should return a valid component', () => { + const plugin = { + template: '
Test
' + } + + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.isVueInstance()).toBe(true) + }) + + describe('Props', () => { + it('should mount with props', () => { + const plugin = { + template: '
{{ name }}
', + props: { + name: { + type: String + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component, { + propsData: { + name: 'Test' + } + }) + expect(wrapper.html()).toBe('
Test
') + }) + + it('should sync changes', () => { + const plugin = { + template: '
{{ name }}
', + props: { + name: { + type: String + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component, { + propsData: { + name: 'Test' + } + }) + expect(wrapper.html()).toBe('
Test
') + wrapper.setProps({ name: 'Jest' }) + expect(wrapper.html()).toBe('
Jest
') + }) + }) + + describe('Data', () => { + it('should mount with data', () => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test' + }) + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.html()).toBe('
Test
') + }) + + it('should not access the parent element', () => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: this && this.$parent && this.$parent._uid + }) + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.vm.name).toBeUndefined() + }) + }) + + describe('Computed', () => { + it('should mount with computed data', () => { + const plugin = { + template: '
{{ name }}
', + computed: { + name () { + return 'Test' + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.html()).toBe('
Test
') + }) + + it('should not access parent element', () => { + const plugin = { + template: '
{{ name }}
', + computed: { + name () { + return this.$parent + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.vm.name).toBeUndefined() + }) + }) + + describe('Methods', () => { + it('should mount with methods', () => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test' + }), + methods: { + change () { + this.name = 'Jest' + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + wrapper.vm.change() + expect(wrapper.vm.name).toBe('Jest') + }) + + it('should call methods from elements', () => { + const plugin = { + template: '', + data: () => ({ + name: 'Test' + }), + methods: { + change () { + this.name = 'Jest' + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + const btn = wrapper.find({ ref: 'btn' }) + btn.trigger('click') + expect(wrapper.vm.name).toBe('Jest') + }) + + it('should call methods from elements with params', () => { + const plugin = { + template: '', + data: () => ({ + name: 'Test', + customName: 'Jest' + }), + methods: { + change (name) { + this.name = name + } + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + const btn = wrapper.find({ ref: 'btn' }) + btn.trigger('click') + expect(wrapper.vm.name).toBe('Jest') + }) + }) + + describe('Created', () => { + it('should mount with created hook', () => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test' + }), + created () { + this.name = 'Jest' + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.vm.name).toBe('Jest') + }) + + it('should not access the parent element', () => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test', + uid: undefined + }), + created () { + this.uid = this.$parent && this.$parent._uid + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.vm.uid).toBeUndefined() + }) + }) + + describe('Mounted', () => { + it('should mount with mounted hook', () => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test' + }), + mounted () { + this.name = 'Jest' + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.vm.name).toBe('Jest') + }) + + it('should access refs', (done) => { + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test', + id: undefined + }), + mounted () { + this.$nextTick(() => { + this.id = this.refs.test.id + }) + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + localVue.nextTick(() => { + expect(wrapper.vm.id).toBe('t1') + done() + }) + }) + + it('should not set custom properties', (done) => { + const spy = jest.spyOn(console, 'error').mockImplementation() + + const plugin = { + template: '
{{ name }}
', + data: () => ({ + name: 'Test' + }), + mounted () { + this.$nextTick(() => { + this.refs.test.innerHTML = 'Jest' + this.refs.test.outerHTML = 'Jest' + this.refs.test.appendChild(document.createElement('p')) + this.refs.test.cloneNode() + this.refs.test.getRootNode() + this.refs.test.insertBefore() + this.refs.test.normalize() + this.refs.test.querySelector() + this.refs.test.querySelectorAll() + this.refs.test.removeChild() + this.refs.test.replaceChild() + }) + } + } + const component = wrapperPlugin(plugin) + const wrapper = mount(component) + expect(wrapper.html()).toBe('
Test
') + + localVue.nextTick(() => { + expect(spy).toHaveBeenCalledWith('innerHTML 🚫') + expect(spy).toHaveBeenCalledWith('outerHTML 🚫') + expect(spy).toHaveBeenCalledWith('appendChild 🚫') + expect(spy).toHaveBeenCalledWith('cloneNode 🚫') + expect(spy).toHaveBeenCalledWith('getRootNode 🚫') + expect(spy).toHaveBeenCalledWith('insertBefore 🚫') + expect(spy).toHaveBeenCalledWith('normalize 🚫') + expect(spy).toHaveBeenCalledWith('querySelector 🚫') + expect(spy).toHaveBeenCalledWith('querySelectorAll 🚫') + expect(spy).toHaveBeenCalledWith('removeChild 🚫') + expect(spy).toHaveBeenCalledWith('replaceChild 🚫') + done() + }) + }) + }) +}) + +const wrapperPlugin = (plugin) => { + return createSafeComponent('test', plugin, localVue) +} diff --git a/__tests__/unit/services/plugin-manager/component/get-context.spec.js b/__tests__/unit/services/plugin-manager/component/get-context.spec.js new file mode 100644 index 0000000000..a4c9bed153 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/component/get-context.spec.js @@ -0,0 +1,71 @@ +import { createLocalVue, mount } from '@vue/test-utils' +import { getSafeContext } from '@/services/plugin-manager/component/get-context' + +let localVue + +beforeEach(() => { + localVue = createLocalVue() +}) + +describe('Prepare Component Context', () => { + describe('Render', () => { + it('should render', () => { + const plugin = { + render (h) { + return h('div', 'Test') + } + } + + const wrapper = mount(createSafeRender(plugin)) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.html()).toBe('
Test
') + }) + + it('should render with computed properties', () => { + const plugin = { + render (h) { + return h('div', this.name) + }, + computed: { + name () { + return 'Test' + } + } + } + + const wrapper = mount(createSafeRender(plugin)) + expect(wrapper.html()).toBe('
Test
') + }) + + it('should not access the parent element', () => { + const plugin = { + render (h) { + return h('div', this.$parent && this.$parent._uid) + } + } + + const wrapper = mount(createSafeRender(plugin)) + expect(wrapper.find('div').text()).toBe('') + }) + + it('should not access the root element', () => { + const plugin = { + render (h) { + return h('div', this.$root && this.$root._uid) + } + } + + const wrapper = mount(createSafeRender(plugin)) + expect(wrapper.find('div').text()).toBe('') + }) + }) +}) + +const createSafeRender = (plugin) => { + return localVue.extend({ + ...plugin, + render: function () { + return plugin.render.apply(getSafeContext(this, plugin), [...arguments]) + } + }) +} diff --git a/__tests__/unit/services/plugin-manager/component/validate.spec.js b/__tests__/unit/services/plugin-manager/component/validate.spec.js new file mode 100644 index 0000000000..3ad5cbb697 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/component/validate.spec.js @@ -0,0 +1,69 @@ +import { validateComponent } from '@/services/plugin-manager/component/validate' + +describe('Validate component', () => { + const plugin = { + config: { + id: 1 + } + } + + it('should have template field', () => { + expect(validateComponent({ plugin, component: {} })).toBe(false) + expect(validateComponent({ plugin, component: { template: '' } })).toBe(true) + }) + + it('should not have unauthorized fields', () => { + const component = { + render: () => {} + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have v-html', () => { + const component = { + template: '
' + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have javascript inline script', () => { + const component = { + template: '
' + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have iframe tag', () => { + const component = { + template: '' + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have webview tag', () => { + const component = { + template: '' + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have script tag', () => { + const component = { + template: '' + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have eval', () => { + const component = { + template: '' + } + expect(validateComponent({ plugin, component })).toBe(false) + }) + + it('should not have inline events', () => { + expect(validateComponent({ plugin, component: { template: '' } })).toBe(false) + expect(validateComponent({ plugin, component: { template: '' } })).toBe(false) + expect(validateComponent({ plugin, component: { template: '' } })).toBe(false) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/plugin-component-sandbox.spec.js b/__tests__/unit/services/plugin-manager/plugin-component-sandbox.spec.js new file mode 100644 index 0000000000..5ad2f5cf97 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/plugin-component-sandbox.spec.js @@ -0,0 +1,118 @@ +import { createLocalVue, mount } from '@vue/test-utils' +import { PluginComponentSandbox } from '@/services/plugin-manager/plugin-component-sandbox' + +jest.mock('@/services/plugin-manager/component/compile-template.js', () => ({ + compileTemplate: jest.fn((vm, template) => { + const { compileToFunctions } = require('vue-template-compiler') + return compileToFunctions(template) + }) +})) + +const vue = createLocalVue() + +const plugin = { + config: { + id: 1 + } +} + +const vm = { + run: jest.fn(code => JSON.parse(code)) +} + +const options = { + vue, + plugin, + componentVM: vm, + pluginVM: vm, + name: 'test' +} + +describe('Plugin Component Sandbox', () => { + it('should return a valid component', () => { + const sandbox = new PluginComponentSandbox({ + ...options, + fullPath: './', + source: { + template: '
Test
' + } + }) + + const component = sandbox.render() + const wrapper = mount(component) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.html()).toBe('
Test
') + }) + + it('should render from buffer', () => { + const sandbox = new PluginComponentSandbox({ + ...options, + fullPath: './', + source: Buffer.from(JSON.stringify({ + template: '
Test
' + })) + }) + const component = sandbox.render() + const wrapper = mount(component) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.html()).toBe('
Test
') + expect(vm.run).toHaveBeenCalled() + }) + + it('should render with global components', () => { + plugin.globalComponents = { + Test: { + template: '
Global
' + } + } + const sandbox = new PluginComponentSandbox({ + ...options, + fullPath: './', + source: { + template: '' + } + }) + const component = sandbox.render() + const wrapper = mount(component) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.html()).toBe('
Global
') + }) + + it('should parse child components', (done) => { + const spy = jest.spyOn(console, 'error').mockImplementation() + + const sandbox = new PluginComponentSandbox({ + ...options, + fullPath: './', + source: { + template: '', + components: { + Test: { + template: '', + data: () => ({ + count: 0 + }), + methods: { + increment () { + this.count++ + } + }, + mounted () { + this.$nextTick(() => { + this.refs.btn.innerHTML = '
' + }) + } + } + } + } + }) + + const component = sandbox.render() + mount(component) + + vue.nextTick(() => { + expect(spy).toHaveBeenCalledWith('innerHTML 🚫') + done() + }) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/alerts-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/alerts-sandbox.spec.js new file mode 100644 index 0000000000..7a7f0adf6c --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/alerts-sandbox.spec.js @@ -0,0 +1,21 @@ +import { createAlertsSandbox } from '@/services/plugin-manager/sandbox/alerts-sandbox' + +const walletApi = {} +const app = { + $error: jest.fn(), + $info: jest.fn(), + $success: jest.fn(), + $warn: jest.fn() +} +const alertsSandbox = createAlertsSandbox(walletApi, app) +alertsSandbox() + +describe('Alerts Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.alert).toBeTruthy() + expect(walletApi.alert.error).toBeTruthy() + expect(walletApi.alert.info).toBeTruthy() + expect(walletApi.alert.success).toBeTruthy() + expect(walletApi.alert.warn).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/audio-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/audio-sandbox.spec.js new file mode 100644 index 0000000000..affe359c82 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/audio-sandbox.spec.js @@ -0,0 +1,13 @@ +import { createAudioSandbox } from '@/services/plugin-manager/sandbox/audio-sandbox' + +global.AudioContext = {} + +const api = {} +const audioSandbox = createAudioSandbox(api) +audioSandbox() + +describe('Audio Sandbox', () => { + it('should expose the AudioContext object', () => { + expect(api.AudioContext).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/events-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/events-sandbox.spec.js new file mode 100644 index 0000000000..c425c546ac --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/events-sandbox.spec.js @@ -0,0 +1,14 @@ +import { createEventsSandbox } from '@/services/plugin-manager/sandbox/events-sandbox' + +const walletApi = {} +const app = { + $eventBus: jest.fn() +} +const eventsSandbox = createEventsSandbox(walletApi, app) +eventsSandbox() + +describe('Events Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.eventBus).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/font-awesome-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/font-awesome-sandbox.spec.js new file mode 100644 index 0000000000..c05376ad24 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/font-awesome-sandbox.spec.js @@ -0,0 +1,11 @@ +import { createFontAwesomeSandbox } from '@/services/plugin-manager/sandbox/font-awesome-sandbox' + +const walletApi = {} +const fontAwesomeSandbox = createFontAwesomeSandbox(walletApi) +fontAwesomeSandbox() + +describe('Font Awesome Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.fontAwesomeIcons).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/http-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/http-sandbox.spec.js new file mode 100644 index 0000000000..a989c2d068 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/http-sandbox.spec.js @@ -0,0 +1,52 @@ +import { createHttpSandbox } from '@/services/plugin-manager/sandbox/http-sandbox' +import got from 'got' + +jest.mock('got') + +const plugin = { + config: { + urls: ['ark.io'] + } +} + +let walletApi +let httpSandbox + +describe('Http Sandbox', () => { + beforeEach(() => { + walletApi = {} + httpSandbox = createHttpSandbox(walletApi, plugin) + httpSandbox() + }) + + it('should expose functions', () => { + expect(walletApi.http).toBeTruthy() + }) + + it('should fail when requesting an unauthorized url', () => { + expect(() => walletApi.http.get('google.com')) + .toThrow('URL "google.com" not allowed') + }) + + it('should get an authrorized url', () => { + walletApi.http.get('ark.io') + expect(got.get).toHaveBeenCalledWith('ark.io', undefined) + }) + + it('should get an authrorized url with options', () => { + const options = { agent: 'jest' } + walletApi.http.get('ark.io', options) + expect(got.get).toHaveBeenCalledWith('ark.io', options) + }) + + it('should post to an authrorized url', () => { + walletApi.http.post('ark.io') + expect(got.post).toHaveBeenCalledWith('ark.io', undefined) + }) + + it('should post to an authrorized url with options', () => { + const options = { version: 2 } + walletApi.http.post('ark.io', options) + expect(got.post).toHaveBeenCalledWith('ark.io', options) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/messaging-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/messaging-sandbox.spec.js new file mode 100644 index 0000000000..c6e31741b0 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/messaging-sandbox.spec.js @@ -0,0 +1,40 @@ +import { createMessagingSandbox } from '@/services/plugin-manager/sandbox/messaging-sandbox' + +let walletApi +let app + +beforeEach(() => { + walletApi = {} + app = { + $router: { + beforeEach: jest.fn() + } + } + + const messagingSandbox = createMessagingSandbox(walletApi, app) + messagingSandbox() +}) + +describe('Messaging Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.messages).toBeTruthy() + expect(walletApi.messages.on).toBeTruthy() + expect(walletApi.messages.clear).toBeTruthy() + }) + + it('should define the router hook', () => { + expect(app.$router.beforeEach).toHaveBeenCalled() + }) + + it('should register listeners', () => { + walletApi.messages.on('transaction', () => 'test') + expect(Object.keys(walletApi.messages.events)).toHaveLength(1) + }) + + it('should clear listeners', () => { + walletApi.messages.on('transaction', () => 'test') + expect(Object.keys(walletApi.messages.events)).toHaveLength(1) + walletApi.messages.clear() + expect(Object.keys(walletApi.messages.events)).toHaveLength(0) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/peer-current-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/peer-current-sandbox.spec.js new file mode 100644 index 0000000000..c1488ddfab --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/peer-current-sandbox.spec.js @@ -0,0 +1,15 @@ +import { createPeerCurrentSandbox } from '@/services/plugin-manager/sandbox/peer-current-sandbox' + +const walletApi = {} +const app = {} +const peerCurrentSandbox = createPeerCurrentSandbox(walletApi, app) +peerCurrentSandbox() + +describe('Peer Current Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.peers).toBeTruthy() + expect(walletApi.peers.current).toBeTruthy() + expect(walletApi.peers.current.get).toBeTruthy() + expect(walletApi.peers.current.post).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/profile-all-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/profile-all-sandbox.spec.js new file mode 100644 index 0000000000..b26ce01384 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/profile-all-sandbox.spec.js @@ -0,0 +1,32 @@ +import { createProfileAllSandbox } from '@/services/plugin-manager/sandbox/profile-all-sandbox' + +const mockGetter = jest.fn(() => [ + { + id: 1 + } +]) + +let walletApi +let app +let profileAllSandbox + +describe('Profile All Sandbox', () => { + beforeEach(() => { + walletApi = {} + app = { + $store: { + getters: { + 'profile/public': mockGetter + } + } + } + profileAllSandbox = createProfileAllSandbox(walletApi, app) + profileAllSandbox() + }) + + it('should expose functions', () => { + expect(mockGetter).toHaveBeenCalled() + expect(walletApi.profiles.all).toBeTruthy() + expect(walletApi.profiles.all).toHaveLength(1) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/profile-current-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/profile-current-sandbox.spec.js new file mode 100644 index 0000000000..2b5ab39a8b --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/profile-current-sandbox.spec.js @@ -0,0 +1,29 @@ +import { createProfileCurrentSandbox } from '@/services/plugin-manager/sandbox/profile-current-sandbox' + +const mockGetter = jest.fn(() => ({ + id: 1 +})) + +let walletApi +let app +let profileCurrentSandbox + +describe('Profile Current Sandbox', () => { + beforeEach(() => { + walletApi = {} + app = { + $store: { + getters: { + 'profile/public': mockGetter + } + } + } + profileCurrentSandbox = createProfileCurrentSandbox(walletApi, app) + profileCurrentSandbox() + }) + + it('should expose functions', () => { + expect(walletApi.profiles).toBeTruthy() + expect(walletApi.profiles.getCurrent).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/route-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/route-sandbox.spec.js new file mode 100644 index 0000000000..95f568f9ee --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/route-sandbox.spec.js @@ -0,0 +1,13 @@ +import { createRouteSandbox } from '@/services/plugin-manager/sandbox/route-sandbox' + +const walletApi = {} +const routeSandbox = createRouteSandbox(walletApi, {}, {}) +routeSandbox() + +describe('Route Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.route).toBeTruthy() + expect(walletApi.route.get).toBeTruthy() + expect(walletApi.route.goTo).toBeTruthy() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/storage-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/storage-sandbox.spec.js new file mode 100644 index 0000000000..b919009cce --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/storage-sandbox.spec.js @@ -0,0 +1,60 @@ +import { createStorageSandbox } from '@/services/plugin-manager/sandbox/storage-sandbox' + +const plugin = { + config: { + id: 'profile1' + } +} + +let walletApi +let app + +beforeAll(() => { + walletApi = {} + const db = {} + + app = { + $store: { + getters: { + 'plugin/pluginOptions': jest.fn(() => db) + }, + dispatch: jest.fn((_, data) => (db[data.key] = data.value)) + } + } + + const storageSandbox = createStorageSandbox(walletApi, app, plugin) + storageSandbox() +}) + +describe('Storage Sandbox', () => { + const options = { + key: 'test', + value: 1, + pluginId: plugin.config.id, + profileId: undefined + } + + it('should expose functions', () => { + expect(walletApi.storage).toBeTruthy() + expect(walletApi.storage.getOptions).toBeTruthy() + expect(walletApi.storage.get).toBeTruthy() + expect(walletApi.storage.set).toBeTruthy() + }) + + it('should set a value to key', () => { + walletApi.storage.set(options.key, options.value) + expect(app.$store.dispatch).toHaveBeenCalledWith('plugin/setPluginOption', options) + }) + + it('should get the value from key', () => { + const result = walletApi.storage.get(options.key) + expect(app.$store.getters['plugin/pluginOptions']).toHaveBeenCalledWith(plugin.config.id, undefined) + expect(result).toBe(options.value) + }) + + it('should get all values', () => { + const result = walletApi.storage.getOptions() + expect(Object.keys(result)).toHaveLength(1) + expect(result).toHaveProperty(options.key, options.value) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/sandbox/timers-sandbox.spec.js b/__tests__/unit/services/plugin-manager/sandbox/timers-sandbox.spec.js new file mode 100644 index 0000000000..0262dc895f --- /dev/null +++ b/__tests__/unit/services/plugin-manager/sandbox/timers-sandbox.spec.js @@ -0,0 +1,100 @@ +import { createTimersSandbox } from '@/services/plugin-manager/sandbox/timers-sandbox' + +const routerNext = jest.fn() + +let walletApi +let app +let sandbox + +beforeEach(() => { + const routeCallbacks = [] + + walletApi = {} + app = { + $router: { + beforeEach: jest.fn(callback => { + routeCallbacks.push(callback) + }), + + push () { + for (const callback of routeCallbacks) { + callback(null, null, routerNext) + } + } + } + } + + sandbox = createTimersSandbox(walletApi, app) + sandbox() +}) + +describe('Timers Sandbox', () => { + it('should expose functions', () => { + expect(walletApi.timers.clearInterval).toBeTruthy() + expect(walletApi.timers.clearTimeout).toBeTruthy() + expect(walletApi.timers.setInterval).toBeTruthy() + expect(walletApi.timers.setTimeout).toBeTruthy() + }) + + it('should clear timer once setTimeout executed', (done) => { + walletApi.timers.setTimeout(() => { + setTimeout(() => { + expect(walletApi.timers.timeouts.length).toEqual(0) + + done() + }, 100) + }, 1000) + expect(walletApi.timers.timeouts.length).toEqual(1) + }) + + it('should clear timer if clearTimeout called', (done) => { + expect(walletApi.timers.timeouts.length).toEqual(0) + const id = walletApi.timers.setTimeout(() => { + throw new Error('This setTimeout call should never execute') + }, 1000) + + walletApi.timers.setTimeout(() => { + done() + }, 1000) + + expect(walletApi.timers.timeouts.length).toEqual(2) + walletApi.timers.clearTimeout(id) + expect(walletApi.timers.timeouts.length).toEqual(1) + }) + + it('should clear timer if clearInterval called', (done) => { + expect(walletApi.timers.intervals.length).toEqual(0) + let counter = 0 + const id = walletApi.timers.setInterval(() => { + counter++ + + if (counter === 5) { + walletApi.timers.clearInterval(id) + setTimeout(() => { + expect(walletApi.timers.intervals.length).toEqual(0) + expect(counter).toEqual(5) + done() + }, 500) + } + }, 100) + expect(walletApi.timers.intervals.length).toEqual(1) + }) + + it('should clear timers on route change', () => { + walletApi.timers.setTimeout(() => { + throw new Error('This setTimeout call will never execute') + }, 10000) + + walletApi.timers.setInterval(() => { + throw new Error('This setInterval call will never execute') + }, 10000) + + expect(walletApi.timers.timeouts.length).toEqual(1) + expect(walletApi.timers.intervals.length).toEqual(1) + expect(app.$router.beforeEach).toHaveBeenCalledTimes(1) + app.$router.push() + expect(routerNext).toHaveBeenCalledTimes(1) + expect(walletApi.timers.timeouts.length).toEqual(0) + expect(walletApi.timers.intervals.length).toEqual(0) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/websocket.spec.js b/__tests__/unit/services/plugin-manager/sandbox/websocket-sandbox.spec.js similarity index 67% rename from __tests__/unit/services/plugin-manager/websocket.spec.js rename to __tests__/unit/services/plugin-manager/sandbox/websocket-sandbox.spec.js index 5d0355489c..e68ec25ce3 100644 --- a/__tests__/unit/services/plugin-manager/websocket.spec.js +++ b/__tests__/unit/services/plugin-manager/sandbox/websocket-sandbox.spec.js @@ -1,5 +1,5 @@ import { Server } from 'mock-socket' -import PluginWebsocket from '@/services/plugin-manager/websocket' +import { createWebsocketSandbox } from '@/services/plugin-manager/sandbox/websocket-sandbox' const whitelist = [ /* eslint-disable: no-useless-escape */ @@ -7,11 +7,20 @@ const whitelist = [ ] const host = 'ws://my.test.com:8080' +const plugin = { + config: { + urls: whitelist + } +} + +let app +let sandbox +let walletApi + let pongMock let mockServer -let router let routerNext -let pluginWebsocket + beforeEach(() => { if (mockServer) { mockServer.stop() @@ -34,24 +43,34 @@ beforeEach(() => { const routeCallbacks = [] routerNext = jest.fn() - router = { - beforeEach: jest.fn(callback => { - routeCallbacks.push(callback) - }), - - push () { - for (const callback of routeCallbacks) { - callback(null, null, routerNext) + + walletApi = {} + + app = { + $router: { + beforeEach: jest.fn(callback => { + routeCallbacks.push(callback) + }), + + push () { + for (const callback of routeCallbacks) { + callback(null, null, routerNext) + } } } } - pluginWebsocket = new PluginWebsocket(whitelist, router) + sandbox = createWebsocketSandbox(walletApi, app, plugin) + sandbox() }) describe('PluginWebsocket', () => { + it('should expose functions', () => { + expect(walletApi.websocket).toBeTruthy() + }) + it('should connect to websocket', (done) => { - const socket = pluginWebsocket.connect(host) + const socket = walletApi.websocket.connect(host) expect(socket.isConnecting()).toBeTrue() setTimeout(() => { @@ -62,7 +81,7 @@ describe('PluginWebsocket', () => { }) it('should connect to websocket and receive data', (done) => { - const socket = pluginWebsocket.connect(host) + const socket = walletApi.websocket.connect(host) socket.on('data', (event) => { expect(event.data).toEqual('test') @@ -73,7 +92,7 @@ describe('PluginWebsocket', () => { }) it('should connect to websocket and send data', (done) => { - const socket = pluginWebsocket.connect(host) + const socket = walletApi.websocket.connect(host) socket.on('pong', (event) => { expect(pongMock).toHaveBeenCalledWith('ping') @@ -87,7 +106,7 @@ describe('PluginWebsocket', () => { }) it('should close the websocket', (done) => { - const socket = pluginWebsocket.connect(host) + const socket = walletApi.websocket.connect(host) setTimeout(() => { expect(socket.isOpen()).toBeTrue() @@ -106,19 +125,19 @@ describe('PluginWebsocket', () => { }) it('should reset websockets on route change', () => { - const socket = pluginWebsocket.connect(host) + const socket = walletApi.websocket.connect(host) socket.on('pong', jest.fn()) expect(socket.events.pong).toBeTruthy() - expect(router.beforeEach).toHaveBeenCalledTimes(1) - router.push() + expect(app.$router.beforeEach).toHaveBeenCalledTimes(1) + app.$router.push() expect(routerNext).toHaveBeenCalledTimes(1) expect(socket.events.length).toEqual(0) }) it('should not connect to websocket', (done) => { - const socket = pluginWebsocket.connect('ws://failure.test.com:8080') + const socket = walletApi.websocket.connect('ws://failure.test.com:8080') setTimeout(() => { expect(socket.isClosed()).toBeTrue() @@ -129,14 +148,19 @@ describe('PluginWebsocket', () => { it('should not connect to websocket due to whitelist', () => { expect(() => { - pluginWebsocket.connect('ws://my.test.com:8081') + walletApi.websocket.connect('ws://my.test.com:8081') }).toThrow('URL "ws://my.test.com:8081" not allowed') }) it('should ignore an invalid whitelist', () => { expect(() => { - pluginWebsocket = new PluginWebsocket('not a whitelist', router) - pluginWebsocket.connect('ws://my.test.com:8081') + const api = {} + createWebsocketSandbox(api, app, { + config: { + url: 'not a whitelist' + } + })() + api.websocket.connect('ws://my.test.com:8081') }).toThrow('URL "ws://my.test.com:8081" not allowed') }) }) diff --git a/__tests__/unit/services/plugin-manager/setup/avatars-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/avatars-setup.spec.js new file mode 100644 index 0000000000..f8f6a0956b --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/avatars-setup.spec.js @@ -0,0 +1,44 @@ +import { createAvatarsSetup } from '@/services/plugin-manager/setup/avatars-setup' +import { Plugin } from '@/services/plugin-manager/plugin' + +const plugin = new Plugin({ + config: { + id: 1 + } +}) + +plugin.components = { + man: {}, + woman: {} +} + +const pluginObject = { + getAvatars: jest.fn(() => ['man', 'woman']) +} + +const sandbox = { + app: { + $store: { + dispatch: jest.fn() + } + } +} + +const profileId = 'profile1' + +const avatarsSetup = createAvatarsSetup(plugin, pluginObject, sandbox, profileId) +avatarsSetup() + +describe('Avatars Setup', () => { + it('should call the getAvatars method', () => { + expect(pluginObject.getAvatars).toHaveBeenCalled() + }) + + it('should populate the avatars field', () => { + expect(plugin.avatars.length).toBeGreaterThan(0) + }) + + it('should dispatch to vuex', () => { + expect(sandbox.app.$store.dispatch).toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/components-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/components-setup.spec.js new file mode 100644 index 0000000000..943666f875 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/components-setup.spec.js @@ -0,0 +1,54 @@ +import { createLocalVue, mount } from '@vue/test-utils' +import { createComponentsSetup } from '@/services/plugin-manager/setup/components-setup' +import { Plugin } from '@/services/plugin-manager/plugin' + +jest.mock('fs', () => ({ + readFileSync: () => ({ + template: '
Test
' + }) +})) + +jest.mock('@/services/plugin-manager/component/compile-template.js', () => ({ + compileTemplate: jest.fn((vm, template) => { + const { compileToFunctions } = require('vue-template-compiler') + return compileToFunctions(template) + }) +})) + +const localVue = createLocalVue() + +const plugin = new Plugin({ + fullPath: './', + config: { + id: 1 + } +}) + +const pluginObject = { + getComponentPaths: jest.fn(() => ({ + test: 'pages/index.js' + })) +} + +const sandbox = { + getVM: jest.fn(() => ({ + run: jest.fn() + })), + app: {} +} + +const componentsSetup = createComponentsSetup(plugin, pluginObject, sandbox, localVue) +componentsSetup() + +describe('Components Setup', () => { + it('should call the getComponentPaths method', () => { + expect(pluginObject.getComponentPaths).toHaveBeenCalled() + }) + + it('should populate the components field', () => { + const componentNames = Object.keys(plugin.components) + expect(componentNames.length).toBeGreaterThan(0) + const wrapper = mount(plugin.components[componentNames[0]]) + expect(wrapper.isVueInstance()).toBe(true) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/menu-items-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/menu-items-setup.spec.js new file mode 100644 index 0000000000..7497c73b8b --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/menu-items-setup.spec.js @@ -0,0 +1,50 @@ +import { createMenuItemsSetup } from '@/services/plugin-manager/setup/menu-items-setup' +import { Plugin } from '@/services/plugin-manager/plugin' + +const plugin = new Plugin({ + config: { + id: 1 + } +}) + +plugin.routes = [ + { + name: 'test' + } +] + +const pluginObject = { + getMenuItems: jest.fn(() => [ + { + routeName: 'test' + } + ]) +} + +const sandbox = { + app: { + $store: { + dispatch: jest.fn() + }, + $router: { + options: { + routes: [] + } + } + } +} + +const profileId = 'profile1' + +const menuItemsSetup = createMenuItemsSetup(plugin, pluginObject, sandbox, profileId) +menuItemsSetup() + +describe('Menu Items Setup', () => { + it('should call the getMenuItems method', () => { + expect(pluginObject.getMenuItems).toHaveBeenCalled() + }) + + it('should dispatch to vuex', () => { + expect(sandbox.app.$store.dispatch).toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/register-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/register-setup.spec.js new file mode 100644 index 0000000000..3f4ee15542 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/register-setup.spec.js @@ -0,0 +1,14 @@ +import { createRegisterSetup } from '@/services/plugin-manager/setup/register-setup' + +const pluginObject = { + register: jest.fn() +} + +const registerSetup = createRegisterSetup(pluginObject) +registerSetup() + +describe('Register Setup', () => { + it('should call the register method', () => { + expect(pluginObject.register).toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/routes-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/routes-setup.spec.js new file mode 100644 index 0000000000..f0580e02ba --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/routes-setup.spec.js @@ -0,0 +1,70 @@ +import { createRoutesSetup } from '@/services/plugin-manager/setup/routes-setup' +import { Plugin } from '@/services/plugin-manager/plugin' + +const pluginObject = { + getRoutes: jest.fn(() => [ + { + name: 'test', + component: 'test' + } + ]) +} + +let plugin +let sandbox +let routesSetup + +beforeEach(() => { + plugin = new Plugin({ + config: { + id: 1 + } + }) + + plugin.components = { + test: {} + } + + sandbox = { + app: { + $router: { + options: { + routes: [] + }, + addRoutes: jest.fn() + } + } + } + + routesSetup = createRoutesSetup(plugin, pluginObject, sandbox) +}) + +describe('Routes Items Setup', () => { + it('should call the getRoutes method', () => { + routesSetup() + expect(pluginObject.getRoutes).toHaveBeenCalled() + }) + + it('should populate the plugin field', () => { + routesSetup() + expect(plugin.routes).toHaveLength(1) + }) + + it('should populate app routes', () => { + routesSetup() + expect(sandbox.app.$router.addRoutes).toHaveBeenCalled() + }) + + it('should not override app routes', () => { + const customSandbox = { ...sandbox } + customSandbox.app.$router.options.routes.push({ + name: 'test' + }) + + const customSetup = createRoutesSetup(plugin, pluginObject, sandbox) + customSetup() + + expect(plugin.routes.length).toBe(0) + expect(sandbox.app.$router.addRoutes).toHaveBeenCalledTimes(0) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/themes-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/themes-setup.spec.js new file mode 100644 index 0000000000..b8d17ed1b9 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/themes-setup.spec.js @@ -0,0 +1,44 @@ +import { createThemesSetup } from '@/services/plugin-manager/setup/themes-setup' + +jest.mock('fs', () => ({ + existsSync: jest.fn(() => true) +})) + +const pluginObject = { + getThemes: jest.fn(() => ({ + test: { + cssPath: 'styles/themes.css', + darkMode: true + } + })) +} + +const sandbox = { + app: { + $store: { + dispatch: jest.fn() + } + } +} + +const plugin = { + fullPath: __dirname, + config: { + id: 1 + } +} + +const profileId = 'profile1' + +const themesSetup = createThemesSetup(plugin, pluginObject, sandbox, profileId) +themesSetup() + +describe('Themes Setup', () => { + it('should call the getThemes method', () => { + expect(pluginObject.getThemes).toHaveBeenCalled() + }) + + it('should dispatch to vuex', () => { + expect(sandbox.app.$store.dispatch).toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/ui-components-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/ui-components-setup.spec.js new file mode 100644 index 0000000000..5147608a03 --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/ui-components-setup.spec.js @@ -0,0 +1,17 @@ +import { Plugin } from '@/services/plugin-manager/plugin' +import { createUiComponentsSetup } from '@/services/plugin-manager/setup/ui-components-setup' + +const plugin = new Plugin({ + config: { + id: 1 + } +}) + +const uiComponentsSetup = createUiComponentsSetup(plugin) +uiComponentsSetup() + +describe('UI Components Setup', () => { + it('should populate the globalComponents field', () => { + expect(Object.keys(plugin.globalComponents).length).toBeGreaterThan(0) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/wallet-tabs-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/wallet-tabs-setup.spec.js new file mode 100644 index 0000000000..7ab054b30c --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/wallet-tabs-setup.spec.js @@ -0,0 +1,44 @@ +import { createWalletTabsSetup } from '@/services/plugin-manager/setup/wallet-tabs-setup' +import { Plugin } from '@/services/plugin-manager/plugin' + +const plugin = new Plugin({ + config: { + id: 1 + } +}) + +plugin.components = { + test: {} +} + +const pluginObject = { + getWalletTabs: jest.fn(() => [ + { + tabTitle: 'Test', + componentName: 'test' + } + ]) +} + +const sandbox = { + app: { + $store: { + dispatch: jest.fn() + } + } +} + +const profileId = 'profile1' + +const walletTabsSetup = createWalletTabsSetup(plugin, pluginObject, sandbox, profileId) +walletTabsSetup() + +describe('Wallet Tabs Setup', () => { + it('should call the getWalletTabs method', () => { + expect(pluginObject.getWalletTabs).toHaveBeenCalled() + }) + + it('should dispatch to vuex', () => { + expect(sandbox.app.$store.dispatch).toHaveBeenCalled() + }) +}) diff --git a/__tests__/unit/services/plugin-manager/setup/webframe-setup.spec.js b/__tests__/unit/services/plugin-manager/setup/webframe-setup.spec.js new file mode 100644 index 0000000000..25cca9312f --- /dev/null +++ b/__tests__/unit/services/plugin-manager/setup/webframe-setup.spec.js @@ -0,0 +1,17 @@ +import { Plugin } from '@/services/plugin-manager/plugin' +import { createWebFrameSetup } from '@/services/plugin-manager/setup/webframe-setup' + +const plugin = new Plugin({ + config: { + id: 1 + } +}) + +const webFrameSetup = createWebFrameSetup(plugin) +webFrameSetup() + +describe('Webframe Setup', () => { + it('should populate the globalComponents field', () => { + expect(Object.keys(plugin.globalComponents)).toHaveLength(1) + }) +}) diff --git a/__tests__/unit/services/plugin-manager/timers.spec.js b/__tests__/unit/services/plugin-manager/timers.spec.js deleted file mode 100644 index ca5f2bf493..0000000000 --- a/__tests__/unit/services/plugin-manager/timers.spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import PluginManager from '@/services/plugin-manager' - -const routerNext = jest.fn() -let router -let sandboxWithTimers -let sandboxWithoutTimers - -beforeEach(() => { - const routeCallbacks = [] - PluginManager.app = { - $router: { - beforeEach: jest.fn(callback => { - routeCallbacks.push(callback) - }), - - push () { - for (const callback of routeCallbacks) { - callback(null, null, routerNext) - } - } - } - } - router = PluginManager.app.$router - - sandboxWithTimers = PluginManager.loadSandbox({ permissions: ['TIMERS'] }) - sandboxWithoutTimers = PluginManager.loadSandbox({ permissions: [] }) -}) - -describe('Timers', () => { - it('should not expose functions without TIMERS permission', () => { - expect(sandboxWithoutTimers.walletApi.timers).toEqual(undefined) - }) - - it('should expose functions with TIMERS permission', () => { - expect(sandboxWithTimers.walletApi.timers.clearInterval).toBeTruthy() - expect(sandboxWithTimers.walletApi.timers.clearTimeout).toBeTruthy() - expect(sandboxWithTimers.walletApi.timers.setInterval).toBeTruthy() - expect(sandboxWithTimers.walletApi.timers.setTimeout).toBeTruthy() - }) - - it('should clear timer once setTimeout executed', (done) => { - sandboxWithTimers.walletApi.timers.setTimeout(() => { - console.log('This is an execution of setTimeout') - setTimeout(() => { - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(0) - - done() - }, 100) - }, 1000) - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(1) - }) - - it('should clear timer if clearTimeout called', (done) => { - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(0) - const id = sandboxWithTimers.walletApi.timers.setTimeout(() => { - console.log('This setTimeout call will never execute') - }, 1000) - sandboxWithTimers.walletApi.timers.setTimeout(() => { - console.log('This is an execution of setTimeout') - - done() - }, 1000) - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(2) - sandboxWithTimers.walletApi.timers.clearTimeout(id) - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(1) - }) - - it('should clear timer if clearInterval called', (done) => { - expect(sandboxWithTimers.walletApi.timers.intervals.length).toEqual(0) - let counter = 0 - const id = sandboxWithTimers.walletApi.timers.setInterval(() => { - counter++ - console.log(`This is execution #${counter} of setInterval`) - if (counter === 5) { - sandboxWithTimers.walletApi.timers.clearInterval(id) - setTimeout(() => { - expect(sandboxWithTimers.walletApi.timers.intervals.length).toEqual(0) - expect(counter).toEqual(5) - done() - }, 500) - } - }, 100) - expect(sandboxWithTimers.walletApi.timers.intervals.length).toEqual(1) - }) - - it('should clear timers on route change', () => { - sandboxWithTimers.walletApi.timers.setTimeout(() => { - console.log('This setTimeout call will never execute') - }, 10000) - - sandboxWithTimers.walletApi.timers.setInterval(() => { - console.log('This setInterval call will never execute') - }, 10000) - - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(1) - expect(sandboxWithTimers.walletApi.timers.intervals.length).toEqual(1) - expect(router.beforeEach).toHaveBeenCalledTimes(1) - router.push() - expect(routerNext).toHaveBeenCalledTimes(1) - expect(sandboxWithTimers.walletApi.timers.timeouts.length).toEqual(0) - expect(sandboxWithTimers.walletApi.timers.intervals.length).toEqual(0) - }) -}) diff --git a/src/renderer/services/plugin-manager.js b/src/renderer/services/plugin-manager.js index 5575eea999..d785df50cf 100644 --- a/src/renderer/services/plugin-manager.js +++ b/src/renderer/services/plugin-manager.js @@ -1,34 +1,23 @@ -import * as fs from 'fs-extra' +import * as fs from 'fs' import * as path from 'path' -import * as vm2 from 'vm2' -import { ipcRenderer } from 'electron' -import { camelCase, cloneDeep, isBoolean, isEmpty, isObject, isString, partition, uniq, upperFirst } from 'lodash' +import * as fsExtra from 'fs-extra' import { PLUGINS } from '@config' -import PluginHttp from '@/services/plugin-manager/http' -import PluginWebsocket from '@/services/plugin-manager/websocket' -import SandboxFontAwesome from '@/services/plugin-manager/font-awesome-sandbox' -import WalletComponents from '@/services/plugin-manager/wallet-components' +import { Plugin } from './plugin-manager/plugin' +import { PluginConfiguration } from './plugin-manager/plugin-configuration' +import { PluginSetup } from './plugin-manager/plugin-setup' +import { PluginSandbox } from './plugin-manager/plugin-sandbox' +import { validatePluginPath } from './plugin-manager/utils/validate-plugin-path' let rootPath = path.resolve(__dirname, '../../../') if (process.env.NODE_ENV === 'production') { rootPath = path.resolve(__dirname, '../../') } -class PluginManager { +export class PluginManager { constructor () { this.plugins = {} - this.pluginRoutes = [] this.hasInit = false this.vue = null - this.hooks = [ - 'created', - 'beforeMount', - 'mounted', - 'beforeUpdate', - 'updated', - 'beforeDestroy', - 'destroyed' - ] } setVue (vue) { @@ -62,441 +51,27 @@ class PluginManager { throw new Error('Plugin is not enabled') } - const pluginObject = plugin.vm.run( - fs.readFileSync(path.join(plugin.fullPath, 'src/index.js')), - path.join(plugin.fullPath, 'src/index.js') - ) - - if (Object.prototype.hasOwnProperty.call(pluginObject, 'register')) { - await pluginObject.register() - } - await this.app.$store.dispatch('plugin/setLoaded', { config: plugin.config, fullPath: plugin.fullPath, profileId }) - const [first, rest] = partition(plugin.config.permissions, permission => { - // These permissions could be necessary first to load others - // The rest does not have dependencies: 'MENU_ITEMS', 'AVATARS', 'WALLET_TABS' - return ['COMPONENTS', 'ROUTES'].includes(permission) + const sandbox = new PluginSandbox({ + plugin, + app: this.app }) - for (const permission of first) { - const method = `loadPlugin${upperFirst(camelCase(permission))}` - if (typeof this[method] === 'function') { - await this[method](pluginObject, plugin, profileId) - } - } - for (const permission of rest) { - const method = `loadPlugin${upperFirst(camelCase(permission))}` - if (typeof this[method] === 'function') { - await this[method](pluginObject, plugin, profileId) - } - } - } - - // TODO hook to clean up and restore or reset values - async disablePlugin (pluginId, profileId) { - if (!this.hasInit) { - throw new Error('Plugin Manager not initiated') - } - - const plugin = this.plugins[pluginId] - if (!plugin) { - throw new Error(`Plugin \`${pluginId}\` not found`) - } - - if (plugin.config.permissions.includes('THEMES')) { - await this.unloadThemes(plugin, profileId) - } - - await this.app.$store.dispatch('plugin/deleteLoaded', plugin.config.id) - } - - async loadPluginComponents (pluginObject, plugin) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getComponentPaths')) { - return - } - - const components = [] - const pluginComponents = await pluginObject.getComponentPaths() - if (pluginComponents && !Array.isArray(pluginComponents) && typeof pluginComponents === 'object') { - for (const componentName of Object.keys(pluginComponents)) { - let componentPath = pluginComponents[componentName] - if (componentPath.indexOf('./') === 0) { - componentPath = `${componentPath.substring(2)}` - } - const fullPath = path.join(plugin.fullPath, 'src', pluginComponents[componentName]) - - const component = plugin.vm.run( - fs.readFileSync(fullPath), - fullPath - ) - - if (!this.validateComponent(plugin, component, componentName)) { - continue - } - - // Inline method to format context based on "this" - const componentContext = function (that, componentData = component) { - let context = that._data - if (!context) { - context = {} - } - - const keys = ['$nextTick', '$refs', '_c', '_v', '_s', '_e', '_m', '_l', '_u'] - for (let key of keys) { - let thatObject = that[key] - - if (key === '$refs' && thatObject) { - key = 'refs' - thatObject = {} - const badGetters = [ - 'attributes', - 'children', - 'childNodes', - 'contentDocument', - 'contentWindow', - 'firstChild', - 'firstElementChild', - 'lastChild', - 'lastElementChild', - 'nextElementSibling', - 'nextSibling', - 'offsetParent', - 'ownerDocument', - 'parentElement', - 'parentNode', - 'shadowRoot', - 'previousElementSibling', - 'previousSibling', - '$root', - '__vue__' - ] - const badSetters = [ - 'innerHTML', - 'outerHTML' - ] - that.$nextTick(() => { - for (const elKey in that.$refs) { - const element = that.$refs[elKey] - - if (!element.tagName || element.tagName.toLowerCase() === 'iframe') { - continue - } - - for (const badGetter of badGetters) { - element.__defineGetter__(badGetter, () => console.log('🚫')) - } - - for (const badSetter of badSetters) { - element.__defineSetter__(badSetter, () => console.log('🚫')) - } - - thatObject[elKey] = element - } - }) - } - - context[key] = thatObject - } - - for (const computedName of Object.keys(componentData.computed || {})) { - context[computedName] = that[computedName] - } - - for (const methodName of Object.keys(componentData.methods || {})) { - context[methodName] = function () { - return componentData.methods[methodName].apply(componentContext(that, componentData)) - } - } - - return context - } - - // Build component object inside vm2 to restrict access for methods - // TODO: Security checks against running from wallet root instead of plugin root. - // Make sure additional files/packages aren't accessible. - const vm = new vm2.NodeVM({ - sandbox: { ...this.loadSandbox(plugin.config), document }, - require: { - builtin: [], - context: 'sandbox', - resolve: function (source) { - return path.resolve(plugin.fullPath, 'src/', source) - }, - external: { - modules: [ - path.resolve(plugin.fullPath, 'src/'), - 'vue/dist/vue.common.js' - ], - transitive: true - }, - root: [ - rootPath, - path.resolve(plugin.fullPath, 'src/') - ] - } - }) - - // TODO: Test accessing 'document' "module.exports =" in the required component file. - const renderedComponent = vm.run( - `const Vue = require('vue/dist/vue.common.js') - const component = require('./${componentPath}') - const compiled = Vue.compile(component.template) - const componentContext = ${componentContext.toString()} - if (compiled.staticRenderFns.length) { - component.render = compiled.render - component.staticRenderFns = compiled.staticRenderFns - } else { - component.render = function () { - return compiled.render.apply(componentContext(this, component), [ ...arguments ]) - } - } - delete component.template - - module.exports = component`, - path.join(rootPath, 'src/vm-component.js') - ) - - for (const methodName of Object.keys(renderedComponent.methods || {})) { - renderedComponent.methods[methodName] = component.methods[methodName] - } - - // Build Vue component - const vmComponent = this.vue.component(componentName, renderedComponent) - - // Fix context of "data" method - if (component.data) { - vmComponent.options.data = function () { return component.data.apply(componentContext(this)) } - } - - // Fix context of "computed" methods - also removes global computed methods - for (const computedName of Object.keys(vmComponent.options.computed)) { - vmComponent.options.computed[computedName] = function () {} - } - - vmComponent.options.created = [function () { - for (const computedName of Object.keys(this.$options.computed)) { - if (component.computed && component.computed[computedName]) { - this.$options.computed[computedName] = component.computed[computedName].bind( - componentContext(this) - ) - this._computedWatchers[computedName].getter = component.computed[computedName].bind( - componentContext(this) - ) - - try { - this._computedWatchers[computedName].run() - } catch (error) { - console.error(error) - } - } - - if (!component.computed || !component.computed[computedName]) { - delete this.$options.computed[computedName] - - try { - this._computedWatchers[computedName].teardown() - } catch (error) { - console.error(error) - } - - delete this._computedWatchers[computedName] - - for (const watcherId in this._watchers) { - if (this._watchers[watcherId].getter.name === computedName) { - try { - this._watchers[watcherId].teardown() - } catch (error) { - console.error(error) - } - - break - } - } - } - } - - if (component.created) { - return component.created.apply(componentContext(this)) - } - }] - - // Fix context of hooks - this.hooks - .filter(hook => Object.prototype.hasOwnProperty.call(component, hook)) - .filter(hook => hook !== 'created') - .forEach(prop => { - const hookMethod = function () { return component[prop].apply(componentContext(this)) } - if (Array.isArray(vmComponent.options[prop])) { - vmComponent.options[prop] = [hookMethod] - } else { - vmComponent.options[prop] = hookMethod - } - }) - - components[componentName] = vmComponent - } - } - - plugin.components = components - } - - async loadPluginRoutes (pluginObject, plugin) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getRoutes')) { - return - } - - const pluginRoutes = this.normalize(await pluginObject.getRoutes()) - if (pluginRoutes && Array.isArray(pluginRoutes) && pluginRoutes.length) { - const allRoutes = this.getAllRoutes() - - const routes = pluginRoutes.reduce((valid, route) => { - if (typeof route.component === 'string' && plugin.components[route.component]) { - if (allRoutes.every(loadedRoute => loadedRoute.name !== route.name)) { - valid.push({ - ...route, - component: plugin.components[route.component] - }) - } - } - return valid - }, []) - - this.pluginRoutes.push(...routes) - this.app.$router.addRoutes(routes) - } - } - - async loadPluginMenuItems (pluginObject, plugin, profileId) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getMenuItems')) { - return - } - - const pluginMenuItems = this.normalize(pluginObject.getMenuItems()) - if (pluginMenuItems && Array.isArray(pluginMenuItems) && pluginMenuItems.length) { - const allRoutes = this.getAllRoutes() - - const menuItems = pluginMenuItems.reduce((valid, menuItem) => { - // Check that the related route exists - if (allRoutes.some(route => route.name === menuItem.routeName)) { - valid.push(menuItem) - } - return valid - }, []) - - await this.app.$store.dispatch('plugin/setMenuItems', { - pluginId: plugin.config.id, - menuItems, - profileId - }) - } - } - - async loadPluginAvatars (pluginObject, plugin, profileId) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getAvatars')) { - return - } - - const pluginAvatars = this.normalize(await pluginObject.getAvatars()) - if (pluginAvatars && Array.isArray(pluginAvatars) && pluginAvatars.length) { - const avatars = [] - for (const avatar of pluginAvatars) { - if (typeof avatar !== 'string' || !plugin.components[avatar]) { - continue - } - - avatars.push(avatar) - } - - plugin.avatars = avatars - - if (avatars.length) { - await this.app.$store.dispatch('plugin/setAvatars', { - pluginId: plugin.config.id, - avatars, - profileId - }) - } - } - } + await sandbox.install() - getAvatarComponents (pluginId) { - const plugin = this.plugins[pluginId] - if (!plugin || !plugin.avatars) { - return {} - } - - const components = {} - for (const avatarName of plugin.avatars) { - if (!plugin.components[avatarName]) { - continue - } - - components[avatarName] = plugin.components[avatarName] - } - - return components - } - - async loadPluginWalletTabs (pluginObject, plugin, profileId) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getWalletTabs')) { - return - } - - const pluginWalletTabs = this.normalize(pluginObject.getWalletTabs()) - if (pluginWalletTabs && Array.isArray(pluginWalletTabs) && pluginWalletTabs.length) { - // Validate the configuration of each tab - const walletTabs = pluginWalletTabs.reduce((valid, walletTab) => { - if (isString(walletTab.tabTitle) && plugin.components[walletTab.componentName]) { - valid.push(walletTab) - } - return valid - }, []) - - if (walletTabs.length) { - await this.app.$store.dispatch('plugin/setWalletTabs', { - pluginId: plugin.config.id, - walletTabs, - profileId - }) - } - } - } - - async loadPluginThemes (pluginObject, plugin, profileId) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getThemes')) { - return - } + const setup = new PluginSetup({ + plugin, + sandbox, + profileId, + vue: this.vue + }) - const pluginThemes = this.normalize(pluginObject.getThemes()) - if (pluginThemes && isObject(pluginThemes)) { - // Validate the configuration of each theme and ensure that their CSS exist - const themes = Object.keys(pluginThemes).reduce((valid, themeName) => { - const config = pluginThemes[themeName] - - if (isBoolean(config.darkMode) && isString(config.cssPath)) { - const cssPath = path.join(plugin.fullPath, 'src', config.cssPath) - if (!fs.existsSync(cssPath)) { - throw new Error(`No file found on \`${config.cssPath}\` for theme "${themeName}"`) - } - - valid[themeName] = { ...config, cssPath } - } - return valid - }, {}) - - if (!isEmpty(themes)) { - await this.app.$store.dispatch('plugin/setThemes', { - pluginId: plugin.config.id, - themes, - profileId - }) - } - } + await setup.install() } async unloadThemes (plugin, profileId) { @@ -510,122 +85,49 @@ class PluginManager { }) } - async loadUnprotectedIframeUrls (pluginObject) { - if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getUnprotectedIframeUrls')) { - return - } + getWalletTabComponent (pluginId) { + const plugin = this.plugins[pluginId] - const urls = pluginObject.getUnprotectedIframeUrls() - if (urls) { - ipcRenderer.send('disable-iframe-protection', urls) + if (!plugin) { + return {} } - } - getWalletTabComponent (pluginId, walletTab) { - const component = this.plugins[pluginId].components[walletTab.componentName] - if (!component) { - throw new Error(`The wallet tab component \`${walletTab.componentName}\` has not be found`) - } - return component + return plugin.getWalletTabComponent() } - validateComponent (plugin, component, name) { - const requiredKeys = ['template'] - const allowedKeys = [ - 'data', - 'methods', - 'computed', - 'components', - ...this.hooks - ] - - const missingKeys = [] - for (const key of requiredKeys) { - if (!Object.prototype.hasOwnProperty.call(component, key)) { - missingKeys.push(key) - } - } - - const componentError = (error, errorType) => { - this.app.$logger.error(`Plugin '${plugin.config.id}' component '${name}' ${errorType}: ${error}`) - } - - if (missingKeys.length) { - componentError(missingKeys.join(', '), 'is missing') - - return false - } + getAvatarComponents (pluginId) { + const plugin = this.plugins[pluginId] - const inlineErrors = [] - if (/v-html/i.test(component.template)) { - inlineErrors.push('uses v-html') - } - if (/javascript:/i.test(component.template)) { - inlineErrors.push('"javascript:"') - } - if (/<\s*webview/i.test(component.template)) { - inlineErrors.push('uses webview tag') - } - if (/<\s*script/i.test(component.template)) { - inlineErrors.push('uses script tag') - } else if (/[^\w]+eval\(/i.test(component.template)) { - inlineErrors.push('uses eval') - } - if (/<\s*iframe/i.test(component.template)) { - inlineErrors.push('uses iframe tag') - } - if (/srcdoc/i.test(component.template)) { - inlineErrors.push('uses srcdoc property') - } - const inlineEvents = [] - for (const event of PLUGINS.validation.events) { - if ((new RegExp(`on${event}`, 'i')).test(component.template)) { - inlineEvents.push(event) - } - } - if (inlineEvents.length) { - inlineErrors.push('events: ' + inlineEvents.join(', ')) + if (!plugin) { + return {} } - if (inlineErrors.length) { - componentError(inlineErrors.join('; '), 'has inline javascript') + return plugin.getAvatarComponents() + } - return false + // TODO hook to clean up and restore or reset values + async disablePlugin (pluginId, profileId) { + if (!this.hasInit) { + throw new Error('Plugin Manager not initiated') } - const bannedKeys = [] - for (const key of Object.keys(component)) { - if (![...requiredKeys, ...allowedKeys].includes(key)) { - bannedKeys.push(key) - } + const plugin = this.plugins[pluginId] + if (!plugin) { + throw new Error(`Plugin \`${pluginId}\` not found`) } - if (bannedKeys.length) { - componentError(bannedKeys.join(', '), 'has unpermitted keys') - - return false + if (plugin.config.permissions.includes('THEMES')) { + await this.unloadThemes(plugin, profileId) } - return true - } - - getAllRoutes () { - return [...this.app.$router.options.routes, ...this.pluginRoutes] - } - - normalize (data) { - return JSON.parse(JSON.stringify(data)) + await this.app.$store.dispatch('plugin/deleteLoaded', plugin.config.id) } async fetchPluginsFromPath (pluginsPath) { - fs.ensureDirSync(pluginsPath) - - const entries = fs.readdirSync(pluginsPath).filter(entry => { - if (fs.lstatSync(`${pluginsPath}/${entry}`).isDirectory()) { - return true - } + fsExtra.ensureDirSync(pluginsPath) - return false + const entries = fsExtra.readdirSync(pluginsPath).filter(entry => { + return fsExtra.lstatSync(`${pluginsPath}/${entry}`).isDirectory() }) for (const entry of entries) { @@ -639,275 +141,28 @@ class PluginManager { } async fetchPlugin (pluginPath) { - this.validatePlugin(pluginPath) + validatePluginPath(pluginPath) - let config = JSON.parse(fs.readFileSync(`${pluginPath}/package.json`)) - config = this.sanitizeConfig(config) + const packageJson = JSON.parse(fs.readFileSync(`${pluginPath}/package.json`)) + const pluginConfig = PluginConfiguration.sanitize(packageJson) - if (!config.id) { - throw new Error('Plugin ID not found') - } else if (!/^[@/a-z-0-9-]+$/.test(config.id)) { - throw new Error('Invalid Plugin ID') - } else if (this.plugins[config.id]) { - throw new Error(`Plugin '${config.id}' has already been loaded`) + if (this.plugins[pluginConfig.id]) { + throw new Error(`Plugin '${pluginConfig.id}' has already been loaded`) } const fullPath = pluginPath.substring(0, 1) === '/' ? pluginPath : path.resolve(pluginPath) await this.app.$store.dispatch('plugin/setAvailable', { - config, + config: pluginConfig, fullPath }) - this.plugins[config.id] = { - config, - path: pluginPath, + this.plugins[pluginConfig.id] = new Plugin({ + config: pluginConfig, + path, fullPath, - vm: new vm2.NodeVM({ - sandbox: this.loadSandbox(config), - require: { - builtin: [], - context: 'sandbox', - external: ['./src', 'vue/dist/vue.common.js'], - root: fullPath - } - }) - } - } - - loadSandbox (config) { - const sandbox = { - walletApi: { - icons: SandboxFontAwesome, - route: { - get: () => { - return { ...this.app.$route, matched: [] } - }, - goTo: routeName => { - const route = this.getAllRoutes().find(route => routeName === route.name) - if (route) { - this.app.$router.push(route) - } - } - } - } - } - - if (!config.permissions || !Array.isArray(config.permissions)) { - return sandbox - } - - if (config.permissions.includes('EVENTS')) { - sandbox.walletApi.eventBus = this.app.$eventBus - } - - if (config.permissions.includes('AUDIO')) { - sandbox.AudioContext = AudioContext - } - - if (config.permissions.includes('ALERTS')) { - sandbox.walletApi.alert = { - error: this.app.$error, - success: this.app.$success, - info: this.app.$info, - warn: this.app.$warn - } - } - - if (config.permissions.includes('TIMERS')) { - const timerArrays = { - intervals: [], - timeouts: [], - timeoutWatchdog: {} - } - - const timers = { - clearInterval (id) { - clearInterval(id) - timerArrays.intervals = timerArrays.intervals.filter(interval => interval !== id) - }, - - clearTimeout (id) { - clearTimeout(id) - clearTimeout(timerArrays.timeoutWatchdog[id]) - delete timerArrays.timeoutWatchdog[id] - timerArrays.timeouts = timerArrays.timeouts.filter(timeout => timeout !== id) - }, - - get intervals () { - return timerArrays.intervals - }, - - get timeouts () { - return timerArrays.timeouts - }, - - setInterval (method, interval, ...args) { - const id = setInterval(function () { - method(...args) - }, interval) - timerArrays.intervals.push(id) - return id - }, - - setTimeout (method, interval, ...args) { - const id = setTimeout(function () { - method(...args) - }, interval) - timerArrays.timeouts.push(id) - timerArrays.timeoutWatchdog[id] = setTimeout(() => timers.clearTimeout(id), interval) - return id - } - } - - this.app.$router.beforeEach((_, __, next) => { - for (const id of timerArrays.intervals) { - clearInterval(id) - } - - for (const id of timerArrays.timeouts) { - clearTimeout(id) - clearTimeout(timerArrays.timeoutWatchdog[id]) - } - - timerArrays.intervals = [] - timerArrays.timeouts = [] - timerArrays.timeoutWatchdog = {} - next() - }) - - sandbox.walletApi.timers = timers - } - - if (config.permissions.includes('MESSAGING')) { - const messages = { - events: [], - - clear () { - for (const eventId in this.events) { - window.removeEventListener('message', this.events[eventId]) - } - - this.events = [] - }, - - on (action, eventCallback) { - const eventTrigger = event => { - if (event.data !== Object(event.data) || event.data.action !== action) { - return - } - - eventCallback(cloneDeep(event.data)) - } - - window.addEventListener('message', eventTrigger) - this.events[action] = eventTrigger - } - } - - this.app.$router.beforeEach((_, __, next) => { - messages.clear() - next() - }) - - sandbox.walletApi.messages = messages - } - - if (config.permissions.includes('STORAGE')) { - sandbox.walletApi.storage = { - get: (key) => { - const options = this.app.$store.getters['plugin/pluginOptions']( - config.id, - this.app.$store.getters['session/profileId'] - ) - - return options[key] - }, - - set: (key, value) => { - this.app.$store.dispatch('plugin/setPluginOption', { - profileId: this.app.$store.getters['session/profileId'], - pluginId: config.id, - key, - value - }) - }, - - getOptions: () => { - return this.app.$store.getters['plugin/pluginOptions']( - config.id, - this.app.$store.getters['session/profileId'] - ) - } - } - } - - sandbox.walletApi.components = WalletComponents(config.permissions) - - if (config.permissions.includes('HTTP')) { - sandbox.walletApi.http = new PluginHttp(config.urls) - } - - if (config.permissions.includes('WEBSOCKETS')) { - sandbox.walletApi.websocket = new PluginWebsocket(config.urls, this.app.$router) - } - - if (config.permissions.includes('PEER_CURRENT')) { - sandbox.walletApi.peers = { - current: { - get: async (url, timeout = 3000) => { - return (await this.app.$client.client.get(url, { timeout })).body - }, - - post: async (url, timeout = 3000) => { - return (await this.app.$client.client.post(url, { timeout })).body - } - } - } - } - - if (config.permissions.includes('PROFILE_CURRENT')) { - sandbox.walletApi.profiles = { - getCurrent: () => { - return this.app.$store.getters['profile/public']() - } - } - } - - if (config.permissions.includes('PROFILE_ALL')) { - if (!sandbox.walletApi.profiles) { - sandbox.walletApi.profiles = {} - } - - sandbox.walletApi.profiles.all = this.app.$store.getters['profile/public'](true) - } - - return sandbox - } - - validatePlugin (pluginPath) { - const structureExists = [ - 'package.json', - 'src', - 'src/index.js' - ] - - for (const pathCheck of structureExists) { - if (!fs.existsSync(path.resolve(pluginPath, pathCheck))) { - throw new Error(`'${pathCheck}' does not exist`) - } - } - } - - sanitizeConfig (config) { - return { - id: config.name, - name: config.title, - description: config.description, - version: config.version, - permissions: uniq(config.permissions).sort(), - urls: config.urls || [] - } + rootPath + }) } } diff --git a/src/renderer/services/plugin-manager/component/compile-template.js b/src/renderer/services/plugin-manager/component/compile-template.js new file mode 100644 index 0000000000..42874265ca --- /dev/null +++ b/src/renderer/services/plugin-manager/component/compile-template.js @@ -0,0 +1,20 @@ +import { getSafeContext } from './get-context' + +export function compileTemplate (vm, template) { + return vm.run( + `const Vue = require('vue/dist/vue.common.js') + const compiled = Vue.compile(${JSON.stringify(template)}) + const prepareContext = ${getSafeContext.toString()} + const component = {} + if (compiled.staticRenderFns.length) { + component.render = compiled.render + component.staticRenderFns = compiled.staticRenderFns + } else { + component.render = function () { + return compiled.render.apply(prepareContext(this, component), [ ...arguments ]) + } + } + module.exports = component`, + 'compile-template.js' + ) +} diff --git a/src/renderer/services/plugin-manager/component/create-component.js b/src/renderer/services/plugin-manager/component/create-component.js new file mode 100644 index 0000000000..8e626dab04 --- /dev/null +++ b/src/renderer/services/plugin-manager/component/create-component.js @@ -0,0 +1,85 @@ +import { getSafeContext } from './get-context' +import { hooks } from './hooks' + +export function createSafeComponent (componentName, baseComponent, vue) { + // Build Vue component + const vmComponent = vue.extend({ + ...baseComponent, + name: componentName + }) + + // Fix context of "data" method + if (baseComponent.data) { + vmComponent.options.data = function safeData () { + return baseComponent.data.apply(getSafeContext(this, baseComponent)) + } + } + + vmComponent.options.created = [function safeCreated () { + if (this.$options.computed) { + for (const computedName of Object.keys(this.$options.computed)) { + if (baseComponent.computed && baseComponent.computed[computedName]) { + this.$options.computed[computedName] = baseComponent.computed[computedName].bind( + getSafeContext(this, baseComponent) + ) + this._computedWatchers[computedName].getter = baseComponent.computed[computedName].bind( + getSafeContext(this, baseComponent) + ) + + try { + this._computedWatchers[computedName].run() + } catch (error) { + console.error(error) + } + } + + if (!baseComponent.computed || !baseComponent.computed[computedName]) { + delete this.$options.computed[computedName] + + try { + this._computedWatchers[computedName].teardown() + } catch (error) { + console.error(error) + } + + delete this._computedWatchers[computedName] + + for (const watcherId in this._watchers) { + if (this._watchers[watcherId].getter.name === computedName) { + try { + this._watchers[watcherId].teardown() + } catch (error) { + console.error(error) + } + + break + } + } + } + } + } + + if (baseComponent.created) { + return baseComponent.created.apply(getSafeContext(this, baseComponent)) + } + }] + + // Fix context of hooks + hooks + .filter(hook => Object.prototype.hasOwnProperty.call(baseComponent, hook)) + .filter(hook => hook !== 'created') + .forEach(prop => { + const componentMethod = baseComponent[prop] + const hookMethod = function safeHook () { + return componentMethod.apply(getSafeContext(this, baseComponent)) + } + + if (Array.isArray(vmComponent.options[prop])) { + vmComponent.options[prop] = [hookMethod] + } else { + vmComponent.options[prop] = hookMethod + } + }) + + return vmComponent +} diff --git a/src/renderer/services/plugin-manager/component/get-context.js b/src/renderer/services/plugin-manager/component/get-context.js new file mode 100644 index 0000000000..ffc3f9fe77 --- /dev/null +++ b/src/renderer/services/plugin-manager/component/get-context.js @@ -0,0 +1,115 @@ +import isElement from 'lodash/isElement' + +export function getSafeContext (vueContext, component) { + const context = vueContext._data || {} + + const keys = ['$nextTick', '_c', '_v', '_s', '_e', '_m', '_l', '_u'] + + for (const key of keys) { + context[key] = vueContext[key] + } + + if (vueContext.$refs) { + const badGetters = [ + 'attributes', + 'children', + 'childNodes', + 'contentDocument', + 'contentWindow', + 'firstChild', + 'firstElementChild', + 'lastChild', + 'lastElementChild', + 'nextElementSibling', + 'nextSibling', + 'offsetParent', + 'ownerDocument', + 'parentElement', + 'parentNode', + 'shadowRoot', + 'previousElementSibling', + 'previousSibling', + '__vue__', + '$root' + ] + + const badSetters = [ + 'innerHTML', + 'outerHTML' + ] + + const badMethods = [ + 'appendChild', + 'cloneNode', + 'getRootNode', + 'insertBefore', + 'normalize', + 'querySelector', + 'querySelectorAll', + 'removeChild', + 'replaceChild' + ] + + const blockElementProperties = (element) => { + if (!isElement(element) || !element.tagName || element.tagName.toLowerCase() === 'iframe') { + return element + } + + for (const badSetter of badSetters) { + try { + element.__defineSetter__(badSetter, () => console.error(`${badSetter} 🚫`)) + } catch { + throw new Error(`Failed to apply ${badSetter} setter to the element. Try wrapping it with '$nextTick'.`) + } + } + + for (const badGetter of badGetters) { + try { + element.__defineGetter__(badGetter, () => console.error(`${badGetter} 🚫`)) + } catch { + throw new Error(`Failed to apply ${badGetter} getter to the element. Try wrapping it with '$nextTick'.`) + } + } + + for (const badMethod of badMethods) { + try { + element[badMethod] = () => console.error(`${badMethod} 🚫`) + } catch { + throw new Error(`Failed to apply ${badMethod} method to the element. Try wrapping it with '$nextTick'.`) + } + } + + return element + } + + context.refs = new Proxy(vueContext.$refs, { + get (target, prop) { + if (prop in target) { + return blockElementProperties(target[prop]) + } + } + }) + } + + if (component.computed) { + for (const computedName of Object.keys(component.computed || {})) { + context[computedName] = vueContext[computedName] + } + } + + if (component.methods) { + for (const methodName of Object.keys(component.methods || {})) { + context[methodName] = function () { + return component.methods[methodName].apply(getSafeContext(vueContext, component), [...arguments]) + } + } + } + + if (vueContext._props) { + for (const propName in vueContext._props) { + context[propName] = vueContext._props[propName] + } + } + + return context +} diff --git a/src/renderer/services/plugin-manager/component/hooks.js b/src/renderer/services/plugin-manager/component/hooks.js new file mode 100644 index 0000000000..fa655f010d --- /dev/null +++ b/src/renderer/services/plugin-manager/component/hooks.js @@ -0,0 +1,9 @@ +export const hooks = [ + 'created', + 'beforeMount', + 'mounted', + 'beforeUpdate', + 'updated', + 'beforeDestroy', + 'destroyed' +] diff --git a/src/renderer/services/plugin-manager/component/validate.js b/src/renderer/services/plugin-manager/component/validate.js new file mode 100644 index 0000000000..7233ef9950 --- /dev/null +++ b/src/renderer/services/plugin-manager/component/validate.js @@ -0,0 +1,85 @@ +import { hooks } from './hooks' +import { PLUGINS } from '@config' + +export function validateComponent ({ plugin, component, name, logger }) { + const requiredKeys = ['template'] + const allowedKeys = [ + 'data', + 'methods', + 'computed', + 'components', + 'props', + ...hooks + ] + + const missingKeys = [] + for (const key of requiredKeys) { + if (!Object.prototype.hasOwnProperty.call(component, key)) { + missingKeys.push(key) + } + } + + const componentError = (error, errorType) => { + if (logger) { + logger.error(`Plugin '${plugin.config.id}' component '${name}' ${errorType}: ${error}`) + } + } + + if (missingKeys.length) { + componentError(missingKeys.join(', '), 'is missing') + + return false + } + + const inlineErrors = [] + if (/v-html/i.test(component.template)) { + inlineErrors.push('uses v-html') + } + if (/javascript:/i.test(component.template)) { + inlineErrors.push('"javascript:"') + } + if (/<\s*webview/i.test(component.template)) { + inlineErrors.push('uses webview tag') + } + if (/<\s*script/i.test(component.template)) { + inlineErrors.push('uses script tag') + } else if (/[^\w]+eval\(/i.test(component.template)) { + inlineErrors.push('uses eval') + } + if (/<\s*iframe/i.test(component.template)) { + inlineErrors.push('uses iframe tag') + } + if (/srcdoc/i.test(component.template)) { + inlineErrors.push('uses srcdoc property') + } + const inlineEvents = [] + for (const event of PLUGINS.validation.events) { + if ((new RegExp(`(^|\\s)+on${event}`, 'i')).test(component.template)) { + inlineEvents.push(event) + } + } + if (inlineEvents.length) { + inlineErrors.push('events: ' + inlineEvents.join(', ')) + } + + if (inlineErrors.length) { + componentError(inlineErrors.join('; '), 'has inline javascript') + + return false + } + + const bannedKeys = [] + for (const key of Object.keys(component)) { + if (![...requiredKeys, ...allowedKeys].includes(key)) { + bannedKeys.push(key) + } + } + + if (bannedKeys.length) { + componentError(bannedKeys.join(', '), 'has unpermitted keys') + + return false + } + + return true +} diff --git a/src/renderer/services/plugin-manager/font-awesome-sandbox.js b/src/renderer/services/plugin-manager/font-awesome-sandbox.js deleted file mode 100644 index e6e918b89f..0000000000 --- a/src/renderer/services/plugin-manager/font-awesome-sandbox.js +++ /dev/null @@ -1,7 +0,0 @@ -import * as FontAwesomeIcons from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' - -export default { - component: FontAwesomeIcon, - icons: FontAwesomeIcons -} diff --git a/src/renderer/services/plugin-manager/plugin-component-sandbox.js b/src/renderer/services/plugin-manager/plugin-component-sandbox.js new file mode 100644 index 0000000000..325f50bac9 --- /dev/null +++ b/src/renderer/services/plugin-manager/plugin-component-sandbox.js @@ -0,0 +1,87 @@ +import { createSafeComponent } from './component/create-component' +import { validateComponent } from './component/validate' +import { compileTemplate } from './component/compile-template' + +export class PluginComponentSandbox { + constructor ({ + fullPath, + name, + plugin, + source, + pluginVM, + componentVM, + vue, + logger + }) { + this.fullPath = fullPath + this.name = name + this.plugin = plugin + this.source = source + this.pluginVM = pluginVM + this.componentVM = componentVM + this.vue = vue + this.logger = logger + + this.compiled = undefined + + this.__compileSource() + } + + /** + * Child components have already been loaded + * by VM and their code is available. + */ + get isFromFilesystem () { + return Buffer.isBuffer(this.source) + } + + cloneSandbox ({ name, source }) { + return new PluginComponentSandbox({ + source: source, + name: name, + fullPath: this.fullPath, + plugin: this.plugin, + pluginVM: this.pluginVM, + componentVM: this.componentVM, + vue: this.vue, + logger: this.logger + }) + } + + /** + * The raw component is validated, parsed and generates + * a secure component to be mounted by Vue. + */ + render () { + if (!validateComponent({ ...this, component: this.compiled })) { + return + } + + const compiledTemplate = compileTemplate(this.pluginVM, this.compiled.template) + + const lazyComponent = Object.assign(compiledTemplate, this.compiled) + delete lazyComponent.template + + const components = this.plugin.globalComponents + + for (const childName of Object.keys(this.compiled.components || {})) { + const childSandbox = this.cloneSandbox({ name: childName, source: this.compiled.components[childName] }) + components[childName] = childSandbox.render() + } + + lazyComponent.components = components + + return createSafeComponent(this.name, lazyComponent, this.vue) + } + + __compileSource () { + if (this.isFromFilesystem) { + this.compiled = this.componentVM.run( + this.source, + this.fullPath + ) + } else { + this.compiled = this.source + } + } +} diff --git a/src/renderer/services/plugin-manager/plugin-configuration.js b/src/renderer/services/plugin-manager/plugin-configuration.js new file mode 100644 index 0000000000..c1cdcaf7be --- /dev/null +++ b/src/renderer/services/plugin-manager/plugin-configuration.js @@ -0,0 +1,38 @@ +import uniq from 'lodash/uniq' + +export class PluginConfiguration { + constructor ({ + id, + name, + description, + permissions, + urls, + version + }) { + this.id = id + this.name = name + this.description = description + this.permissions = permissions + this.urls = urls + this.version = version + } + + static sanitize (config) { + return new PluginConfiguration({ + id: config.name, + name: config.title, + description: config.description, + version: config.version, + permissions: uniq(config.permissions).sort(), + urls: config.urls || [] + }) + } + + validate () { + if (!this.id) { + throw new Error('Plugin ID not found') + } else if (!/^[@/a-z-0-9-]+$/.test(this.id)) { + throw new Error('Invalid Plugin ID') + } + } +} diff --git a/src/renderer/services/plugin-manager/plugin-permission.js b/src/renderer/services/plugin-manager/plugin-permission.js new file mode 100644 index 0000000000..67f2aecfc9 --- /dev/null +++ b/src/renderer/services/plugin-manager/plugin-permission.js @@ -0,0 +1,30 @@ +class Permission { + constructor ({ + name, + description + }) { + this.name = name + this.description = description + } +} + +export const AVATARS = new Permission({ name: 'AVATARS' }) +export const COMPONENTS = new Permission({ name: 'COMPONENTS' }) +export const HTTP = new Permission({ name: 'HTTP' }) +export const MENU_ITEMS = new Permission({ name: 'MENU_ITEMS' }) +export const MESSAGING = new Permission({ name: 'MESSAGING' }) +export const PUBLIC = new Permission({ name: 'PUBLIC' }) +export const ROUTES = new Permission({ name: 'ROUTES' }) +export const THEMES = new Permission({ name: 'THEMES' }) +export const TIMERS = new Permission({ name: 'TIMERS' }) +export const UI_COMPONENTS = new Permission({ name: 'UI_COMPONENTS' }) +export const WALLET_TABS = new Permission({ name: 'WALLET_TABS' }) +export const WEBFRAME = new Permission({ name: 'WEBFRAME' }) +export const WEBSOCKET = new Permission({ name: 'WEBSOCKET' }) +export const PROFILE_ALL = new Permission({ name: 'PROFILE_ALL' }) +export const PROFILE_CURRENT = new Permission({ name: 'PROFILE_CURRENT' }) +export const PEER_CURRENT = new Permission({ name: 'PEER_CURRENT' }) +export const STORAGE = new Permission({ name: 'STORAGE' }) +export const AUDIO = new Permission({ name: 'AUDIO' }) +export const EVENTS = new Permission({ name: 'EVENTS' }) +export const ALERTS = new Permission({ name: 'ALERTS' }) diff --git a/src/renderer/services/plugin-manager/plugin-sandbox.js b/src/renderer/services/plugin-manager/plugin-sandbox.js new file mode 100644 index 0000000000..32fd32f007 --- /dev/null +++ b/src/renderer/services/plugin-manager/plugin-sandbox.js @@ -0,0 +1,119 @@ +/* eslint-disable no-unused-vars */ +import path from 'path' +import { castArray } from 'lodash' +import { NodeVM } from 'vm2' +import { + HTTP, + MESSAGING, + WEBSOCKET, + PUBLIC, + TIMERS, + PROFILE_ALL, + PROFILE_CURRENT, + PEER_CURRENT, + STORAGE, + AUDIO, + EVENTS, + ALERTS +} from './plugin-permission' +import { createHttpSandbox } from './sandbox/http-sandbox' +import { createMessagingSandbox } from './sandbox/messaging-sandbox' +import { createWebsocketSandbox } from './sandbox/websocket-sandbox' +import { createFontAwesomeSandbox } from './sandbox/font-awesome-sandbox' +import { createRouteSandbox } from './sandbox/route-sandbox' +import { createTimersSandbox } from './sandbox/timers-sandbox' +import { createProfileAllSandbox } from './sandbox/profile-all-sandbox' +import { createProfileCurrentSandbox } from './sandbox/profile-current-sandbox' +import { createPeerCurrentSandbox } from './sandbox/peer-current-sandbox' +import { createStorageSandbox } from './sandbox/storage-sandbox' +import { createAudioSandbox } from './sandbox/audio-sandbox' +import { createEventsSandbox } from './sandbox/events-sandbox' +import { createAlertsSandbox } from './sandbox/alerts-sandbox' + +export class PluginSandbox { + constructor ({ + app, + plugin + }) { + this.app = app + this.plugin = plugin + + this.sandbox = {} + this.walletApi = {} + + this.sandboxes = this.__mapPermissionsToSandbox() + } + + getSandbox (loadApi = true) { + if (loadApi) { + return { + ...this.sandbox, + walletApi: this.walletApi + } + } + + return { + document + } + } + + getVM ({ loadApi }) { + const fullPath = this.plugin.fullPath + + return new NodeVM({ + sandbox: this.getSandbox(loadApi), + require: { + builtin: [], + context: 'sandbox', + resolve: function (source) { + return path.resolve(fullPath, source) + }, + external: { + modules: [ + path.resolve(fullPath), + 'vue/dist/vue.common.js' + ], + transitive: true + }, + root: [ + this.plugin.rootPath, + path.resolve(fullPath) + ] + } + }) + } + + async install () { + await this.__run(this.sandboxes[PUBLIC.name]) + + for (const permissionName of this.plugin.config.permissions) { + await this.__run(this.sandboxes[permissionName]) + } + } + + async __run (sandboxes = []) { + for (const sandbox of castArray(sandboxes)) { + await sandbox() + } + } + + __mapPermissionsToSandbox () { + return { + [ALERTS.name]: createAlertsSandbox(this.walletApi, this.app), + [AUDIO.name]: createAudioSandbox(this.sandbox), + [EVENTS.name]: createEventsSandbox(this.walletApi, this.app), + [HTTP.name]: createHttpSandbox(this.walletApi, this.plugin), + [MESSAGING.name]: createMessagingSandbox(this.walletApi, this.app), + [PEER_CURRENT.name]: createPeerCurrentSandbox(this.walletApi, this.app), + [PROFILE_ALL.name]: createProfileAllSandbox(this.walletApi, this.app), + [PROFILE_CURRENT.name]: createProfileCurrentSandbox(this.walletApi, this.app), + [PUBLIC.name]: [ + createFontAwesomeSandbox(this.walletApi), + createRouteSandbox(this.walletApi, this.plugin, this.app) + ], + [STORAGE.name]: createStorageSandbox(this.walletApi, this.app, this.plugin), + [TIMERS.name]: createTimersSandbox(this.walletApi, this.app), + [WEBSOCKET.name]: createWebsocketSandbox(this.walletApi, this.app, this.plugin) + } + } +} diff --git a/src/renderer/services/plugin-manager/plugin-setup.js b/src/renderer/services/plugin-manager/plugin-setup.js new file mode 100644 index 0000000000..c28588a2cc --- /dev/null +++ b/src/renderer/services/plugin-manager/plugin-setup.js @@ -0,0 +1,89 @@ +import path from 'path' +import fs from 'fs' +import { castArray } from 'lodash' +import { + COMPONENTS, + AVATARS, + WALLET_TABS, + ROUTES, + PUBLIC, + MENU_ITEMS, + THEMES, + WEBFRAME, + UI_COMPONENTS +} from './plugin-permission' +import { createComponentsSetup } from './setup/components-setup' +import { createAvatarsSetup } from './setup/avatars-setup' +import { createRoutesSetup } from './setup/routes-setup' +import { createWalletTabsSetup } from './setup/wallet-tabs-setup' +import { createRegisterSetup } from './setup/register-setup' +import { createMenuItemsSetup } from './setup/menu-items-setup' +import { createThemesSetup } from './setup/themes-setup' +import { createWebFrameSetup } from './setup/webframe-setup' +import { createUiComponentsSetup } from './setup/ui-components-setup' +import { createFontAwesomeSetup } from './setup/font-awesome-setup' + +export class PluginSetup { + constructor ({ + plugin, + sandbox, + vue, + profileId + }) { + this.plugin = plugin + this.sandbox = sandbox + this.profileId = profileId + + const localVue = vue.extend() + localVue.options._base = localVue + this.vue = localVue + + this.pluginObject = this.sandbox.getVM(false).run( + fs.readFileSync(path.join(plugin.fullPath, 'src/index.js')), + path.join(plugin.fullPath, 'src/index.js') + ) + + this.setups = this.__mapPermissionsToSetup() + } + + async install () { + await this.__run(this.setups[PUBLIC.name]) + + const permissions = this.plugin.config.permissions + const priorities = [WEBFRAME.name, UI_COMPONENTS.name, COMPONENTS.name, ROUTES.name] + + const first = priorities.filter(p => permissions.includes(p)) + const rest = permissions.filter(p => !priorities.includes(p)) + + for (const permissionName of first) { + await this.__run(this.setups[permissionName]) + } + + for (const permissionName of rest) { + await this.__run(this.setups[permissionName]) + } + } + + async __run (setups = []) { + for (const setup of castArray(setups)) { + await setup() + } + } + + __mapPermissionsToSetup () { + return { + [AVATARS.name]: createAvatarsSetup(this.plugin, this.pluginObject, this.sandbox, this.profileId), + [WALLET_TABS.name]: createWalletTabsSetup(this.plugin, this.pluginObject, this.sandbox, this.profileId), + [ROUTES.name]: createRoutesSetup(this.plugin, this.pluginObject, this.sandbox), + [COMPONENTS.name]: createComponentsSetup(this.plugin, this.pluginObject, this.sandbox, this.vue), + [MENU_ITEMS.name]: createMenuItemsSetup(this.plugin, this.pluginObject, this.sandbox, this.profileId), + [THEMES.name]: createThemesSetup(this.plugin, this.pluginObject, this.sandbox, this.profileId), + [PUBLIC.name]: [ + createRegisterSetup(this.pluginObject), + createFontAwesomeSetup(this.plugin) + ], + [WEBFRAME.name]: createWebFrameSetup(this.plugin), + [UI_COMPONENTS.name]: createUiComponentsSetup(this.plugin) + } + } +} diff --git a/src/renderer/services/plugin-manager/plugin.js b/src/renderer/services/plugin-manager/plugin.js new file mode 100644 index 0000000000..5cbb388c84 --- /dev/null +++ b/src/renderer/services/plugin-manager/plugin.js @@ -0,0 +1,44 @@ +export class Plugin { + constructor ({ + config, + path, + fullPath, + rootPath + }) { + this.config = config + this.path = path + this.fullPath = fullPath + this.rootPath = rootPath + + this.globalComponents = {} + this.components = {} + this.avatars = [] + this.routes = [] + } + + getAvatarComponents () { + if (!this.avatars) { + return {} + } + + const components = {} + for (const avatarName of this.avatars) { + if (!this.components[avatarName]) { + continue + } + + components[avatarName] = this.components[avatarName] + } + + return components + } + + getWalletTabComponent (walletTab) { + const component = this.components[walletTab.componentName] + if (!component) { + throw new Error(`The wallet tab component \`${walletTab.componentName}\` has not be found`) + } + + return component + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/alerts-sandbox.js b/src/renderer/services/plugin-manager/sandbox/alerts-sandbox.js new file mode 100644 index 0000000000..62dfaa2e9b --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/alerts-sandbox.js @@ -0,0 +1,10 @@ +export function createAlertsSandbox (walletApi, app) { + return () => { + walletApi.alert = { + error: app.$error, + success: app.$success, + info: app.$info, + warn: app.$warn + } + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/audio-sandbox.js b/src/renderer/services/plugin-manager/sandbox/audio-sandbox.js new file mode 100644 index 0000000000..d564120898 --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/audio-sandbox.js @@ -0,0 +1,5 @@ +export function createAudioSandbox (sandbox) { + return () => { + sandbox.AudioContext = AudioContext + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/events-sandbox.js b/src/renderer/services/plugin-manager/sandbox/events-sandbox.js new file mode 100644 index 0000000000..38cef71a40 --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/events-sandbox.js @@ -0,0 +1,5 @@ +export function createEventsSandbox (walletApi, app) { + return () => { + walletApi.eventBus = app.$eventBus + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/font-awesome-sandbox.js b/src/renderer/services/plugin-manager/sandbox/font-awesome-sandbox.js new file mode 100644 index 0000000000..ab305d943d --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/font-awesome-sandbox.js @@ -0,0 +1,7 @@ +import * as FontAwesomeIcons from '@fortawesome/free-solid-svg-icons' + +export function createFontAwesomeSandbox (walletApi) { + return () => { + walletApi.fontAwesomeIcons = FontAwesomeIcons + } +} diff --git a/src/renderer/services/plugin-manager/http.js b/src/renderer/services/plugin-manager/sandbox/http-sandbox.js similarity index 80% rename from src/renderer/services/plugin-manager/http.js rename to src/renderer/services/plugin-manager/sandbox/http-sandbox.js index 9435da9cd6..bec77b27c4 100644 --- a/src/renderer/services/plugin-manager/http.js +++ b/src/renderer/services/plugin-manager/sandbox/http-sandbox.js @@ -1,6 +1,6 @@ import got from 'got' -export default class PluginHttp { +class PluginHttp { constructor (whitelist) { this.whitelist = [] @@ -38,3 +38,9 @@ export default class PluginHttp { return got.post(url, opts) } } + +export function createHttpSandbox (walletApi, plugin) { + return () => { + walletApi.http = new PluginHttp(plugin.config.urls) + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/messaging-sandbox.js b/src/renderer/services/plugin-manager/sandbox/messaging-sandbox.js new file mode 100644 index 0000000000..a95e8213b2 --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/messaging-sandbox.js @@ -0,0 +1,37 @@ +import cloneDeep from 'lodash/cloneDeep' + +export function createMessagingSandbox (walletApi, app) { + return () => { + const messages = { + events: {}, + + clear () { + for (const eventId in this.events) { + window.removeEventListener('message', this.events[eventId]) + } + + this.events = {} + }, + + on (action, eventCallback) { + const eventTrigger = event => { + if (event.data !== Object(event.data) || event.data.action !== action) { + return + } + + eventCallback(cloneDeep(event.data)) + } + + window.addEventListener('message', eventTrigger) + this.events[action] = eventTrigger + } + } + + app.$router.beforeEach((_, __, next) => { + messages.clear() + next() + }) + + walletApi.messages = messages + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/peer-current-sandbox.js b/src/renderer/services/plugin-manager/sandbox/peer-current-sandbox.js new file mode 100644 index 0000000000..f077dfa55d --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/peer-current-sandbox.js @@ -0,0 +1,15 @@ +export function createPeerCurrentSandbox (walletApi, app) { + return () => { + walletApi.peers = { + current: { + get: async (url, timeout = 3000) => { + return (await app.$client.client.get(url, { timeout })).body + }, + + post: async (url, timeout = 3000) => { + return (await app.$client.client.post(url, { timeout })).body + } + } + } + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/profile-all-sandbox.js b/src/renderer/services/plugin-manager/sandbox/profile-all-sandbox.js new file mode 100644 index 0000000000..1e235abdbc --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/profile-all-sandbox.js @@ -0,0 +1,9 @@ +export function createProfileAllSandbox (walletApi, app) { + return () => { + if (!walletApi.profiles) { + walletApi.profiles = {} + } + + walletApi.profiles.all = app.$store.getters['profile/public'](true) + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/profile-current-sandbox.js b/src/renderer/services/plugin-manager/sandbox/profile-current-sandbox.js new file mode 100644 index 0000000000..66fe771101 --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/profile-current-sandbox.js @@ -0,0 +1,15 @@ +export function createProfileCurrentSandbox (walletApi, app) { + return () => { + if (!walletApi.profiles) { + walletApi.profiles = {} + } + + walletApi.profiles = { + ...walletApi.profiles, + + getCurrent: () => { + return app.$store.getters['profile/public']() + } + } + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/route-sandbox.js b/src/renderer/services/plugin-manager/sandbox/route-sandbox.js new file mode 100644 index 0000000000..48edb02154 --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/route-sandbox.js @@ -0,0 +1,17 @@ +import { getAllRoutes } from '../utils/get-all-routes' + +export function createRouteSandbox (walletApi, plugin, app) { + return () => { + walletApi.route = { + get: () => { + return { ...app.$route, matched: [] } + }, + goTo: routeName => { + const route = getAllRoutes(app, plugin).find(route => routeName === route.name) + if (route) { + app.$router.push(route) + } + } + } + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/storage-sandbox.js b/src/renderer/services/plugin-manager/sandbox/storage-sandbox.js new file mode 100644 index 0000000000..89bd75d4fa --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/storage-sandbox.js @@ -0,0 +1,30 @@ +export function createStorageSandbox (walletApi, app, plugin) { + return () => { + walletApi.storage = { + get: (key) => { + const options = app.$store.getters['plugin/pluginOptions']( + plugin.config.id, + app.$store.getters['session/profileId'] + ) + + return options && options[key] + }, + + set: (key, value) => { + app.$store.dispatch('plugin/setPluginOption', { + profileId: app.$store.getters['session/profileId'], + pluginId: plugin.config.id, + key, + value + }) + }, + + getOptions: () => { + return app.$store.getters['plugin/pluginOptions']( + plugin.config.id, + app.$store.getters['session/profileId'] + ) + } + } + } +} diff --git a/src/renderer/services/plugin-manager/sandbox/timers-sandbox.js b/src/renderer/services/plugin-manager/sandbox/timers-sandbox.js new file mode 100644 index 0000000000..b554b134d6 --- /dev/null +++ b/src/renderer/services/plugin-manager/sandbox/timers-sandbox.js @@ -0,0 +1,66 @@ +export function createTimersSandbox (walletApi, app) { + return () => { + const timerArrays = { + intervals: [], + timeouts: [], + timeoutWatchdog: {} + } + + const timers = { + clearInterval (id) { + clearInterval(id) + timerArrays.intervals = timerArrays.intervals.filter(interval => interval !== id) + }, + + clearTimeout (id) { + clearTimeout(id) + clearTimeout(timerArrays.timeoutWatchdog[id]) + delete timerArrays.timeoutWatchdog[id] + timerArrays.timeouts = timerArrays.timeouts.filter(timeout => timeout !== id) + }, + + get intervals () { + return timerArrays.intervals + }, + + get timeouts () { + return timerArrays.timeouts + }, + + setInterval (method, interval, ...args) { + const id = setInterval(function () { + method(...args) + }, interval) + timerArrays.intervals.push(id) + return id + }, + + setTimeout (method, interval, ...args) { + const id = setTimeout(function () { + method(...args) + }, interval) + timerArrays.timeouts.push(id) + timerArrays.timeoutWatchdog[id] = setTimeout(() => timers.clearTimeout(id), interval) + return id + } + } + + app.$router.beforeEach((_, __, next) => { + for (const id of timerArrays.intervals) { + clearInterval(id) + } + + for (const id of timerArrays.timeouts) { + clearTimeout(id) + clearTimeout(timerArrays.timeoutWatchdog[id]) + } + + timerArrays.intervals = [] + timerArrays.timeouts = [] + timerArrays.timeoutWatchdog = {} + next() + }) + + walletApi.timers = timers + } +} diff --git a/src/renderer/services/plugin-manager/websocket.js b/src/renderer/services/plugin-manager/sandbox/websocket-sandbox.js similarity index 91% rename from src/renderer/services/plugin-manager/websocket.js rename to src/renderer/services/plugin-manager/sandbox/websocket-sandbox.js index 0e94c8ddea..3158a2035f 100644 --- a/src/renderer/services/plugin-manager/websocket.js +++ b/src/renderer/services/plugin-manager/sandbox/websocket-sandbox.js @@ -1,4 +1,4 @@ -export default class PluginWebsocket { +class PluginWebsocket { constructor (whitelist, router) { this.whitelist = [] this.router = router @@ -97,3 +97,9 @@ export default class PluginWebsocket { return websocketEvents } } + +export function createWebsocketSandbox (walletApi, app, plugin) { + return () => { + walletApi.websocket = new PluginWebsocket(plugin.config.urls, app.$router) + } +} diff --git a/src/renderer/services/plugin-manager/setup/avatars-setup.js b/src/renderer/services/plugin-manager/setup/avatars-setup.js new file mode 100644 index 0000000000..9d4a01693a --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/avatars-setup.js @@ -0,0 +1,31 @@ +import { normalizeJson } from '../utils/normalize-json' + +export function createAvatarsSetup (plugin, pluginObject, sandbox, profileId) { + return async () => { + if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getAvatars')) { + return + } + + const pluginAvatars = normalizeJson(await pluginObject.getAvatars()) + if (pluginAvatars && Array.isArray(pluginAvatars) && pluginAvatars.length) { + const avatars = [] + for (const avatar of pluginAvatars) { + if (typeof avatar !== 'string' || !plugin.components[avatar]) { + continue + } + + avatars.push(avatar) + } + + plugin.avatars = avatars + + if (avatars.length) { + await sandbox.app.$store.dispatch('plugin/setAvatars', { + pluginId: plugin.config.id, + avatars, + profileId + }) + } + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/components-setup.js b/src/renderer/services/plugin-manager/setup/components-setup.js new file mode 100644 index 0000000000..16997e2e9a --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/components-setup.js @@ -0,0 +1,41 @@ + +import fs from 'fs' +import path from 'path' +import { PluginComponentSandbox } from '../plugin-component-sandbox' + +export function createComponentsSetup (plugin, pluginObject, sandbox, vue) { + return () => { + if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getComponentPaths')) { + return + } + + const pluginComponents = pluginObject.getComponentPaths() + const components = {} + + for (const componentName of Object.keys(pluginComponents)) { + const fullPath = path.join(plugin.fullPath, 'src', pluginComponents[componentName]) + const source = fs.readFileSync(fullPath) + + if (source) { + const component = new PluginComponentSandbox({ + source, + plugin, + vue, + fullPath, + name: componentName, + pluginVM: sandbox.getVM({ loadApi: false }), + componentVM: sandbox.getVM({ loadApi: true }), + logger: sandbox.app.$logger + }) + + const vmComponent = component.render() + + if (vmComponent) { + components[componentName] = vmComponent + } + } + } + + plugin.components = components + } +} diff --git a/src/renderer/services/plugin-manager/setup/font-awesome-setup.js b/src/renderer/services/plugin-manager/setup/font-awesome-setup.js new file mode 100644 index 0000000000..8273b62a69 --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/font-awesome-setup.js @@ -0,0 +1,7 @@ +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' + +export function createFontAwesomeSetup (plugin) { + return () => { + plugin.globalComponents[FontAwesomeIcon.name] = FontAwesomeIcon + } +} diff --git a/src/renderer/services/plugin-manager/setup/menu-items-setup.js b/src/renderer/services/plugin-manager/setup/menu-items-setup.js new file mode 100644 index 0000000000..97016aee65 --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/menu-items-setup.js @@ -0,0 +1,30 @@ +import { normalizeJson } from '../utils/normalize-json' +import { getAllRoutes } from '../utils/get-all-routes' + +export function createMenuItemsSetup (plugin, pluginObject, sandbox, profileId) { + return async () => { + if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getMenuItems')) { + return + } + + const pluginMenuItems = normalizeJson(pluginObject.getMenuItems()) + if (pluginMenuItems && Array.isArray(pluginMenuItems) && pluginMenuItems.length) { + const allRoutes = getAllRoutes(sandbox.app, plugin) + const menuItems = pluginMenuItems.reduce((valid, menuItem) => { + // Check that the related route exists + if (allRoutes.some(route => route.name === menuItem.routeName)) { + valid.push(menuItem) + } + return valid + }, []) + + if (menuItems.length) { + await sandbox.app.$store.dispatch('plugin/setMenuItems', { + pluginId: plugin.config.id, + menuItems, + profileId + }) + } + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/register-setup.js b/src/renderer/services/plugin-manager/setup/register-setup.js new file mode 100644 index 0000000000..1bfbd3ac7e --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/register-setup.js @@ -0,0 +1,7 @@ +export function createRegisterSetup (pluginObject) { + return async () => { + if (Object.prototype.hasOwnProperty.call(pluginObject, 'register')) { + await pluginObject.register() + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/routes-setup.js b/src/renderer/services/plugin-manager/setup/routes-setup.js new file mode 100644 index 0000000000..a462326c8e --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/routes-setup.js @@ -0,0 +1,32 @@ +import { normalizeJson } from '../utils/normalize-json' +import { getAllRoutes } from '../utils/get-all-routes' + +export function createRoutesSetup (plugin, pluginObject, sandbox) { + return () => { + if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getRoutes')) { + return + } + + const pluginRoutes = normalizeJson(pluginObject.getRoutes()) + if (pluginRoutes && Array.isArray(pluginRoutes) && pluginRoutes.length) { + const allRoutes = getAllRoutes(sandbox.app) + + const routes = pluginRoutes.reduce((valid, route) => { + if (typeof route.component === 'string' && plugin.components[route.component]) { + if (allRoutes.every(loadedRoute => loadedRoute.name !== route.name)) { + valid.push({ + ...route, + component: plugin.components[route.component] + }) + } + } + return valid + }, []) + + if (routes.length) { + plugin.routes.push(...routes) + sandbox.app.$router.addRoutes(routes) + } + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/themes-setup.js b/src/renderer/services/plugin-manager/setup/themes-setup.js new file mode 100644 index 0000000000..caf67e2a76 --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/themes-setup.js @@ -0,0 +1,38 @@ +import path from 'path' +import fs from 'fs' +import { normalizeJson } from '../utils/normalize-json' +import { isEmpty, isString, isObject, isBoolean } from 'lodash' + +export function createThemesSetup (plugin, pluginObject, sandbox, profileId) { + return async () => { + if (!Object.prototype.hasOwnProperty.call(pluginObject, 'getThemes')) { + return + } + + const pluginThemes = normalizeJson(pluginObject.getThemes()) + if (pluginThemes && isObject(pluginThemes)) { + // Validate the configuration of each theme and ensure that their CSS exist + const themes = Object.keys(pluginThemes).reduce((valid, themeName) => { + const config = pluginThemes[themeName] + + if (isBoolean(config.darkMode) && isString(config.cssPath)) { + const cssPath = path.join(plugin.fullPath, 'src', config.cssPath) + if (!fs.existsSync(cssPath)) { + throw new Error(`No file found on \`${config.cssPath}\` for theme "${themeName}"`) + } + + valid[themeName] = { ...config, cssPath } + } + return valid + }, {}) + + if (!isEmpty(themes)) { + await sandbox.app.$store.dispatch('plugin/setThemes', { + pluginId: plugin.config.id, + themes, + profileId + }) + } + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/ui-components-setup.js b/src/renderer/services/plugin-manager/setup/ui-components-setup.js new file mode 100644 index 0000000000..fdf205a600 --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/ui-components-setup.js @@ -0,0 +1,26 @@ +import * as ButtonComponents from '@/components/Button' +import * as CollapseComponents from '@/components/Collapse' +import * as InputComponents from '@/components/Input' +import * as ListDividedComponents from '@/components/ListDivided' +import * as MenuComponents from '@/components/Menu' +import Loader from '@/components/utils/Loader' +import TableWrapper from '@/components/utils/TableWrapper' + +export function createUiComponentsSetup (plugin) { + return () => { + const components = { + ...ButtonComponents, + ...CollapseComponents, + ...InputComponents, + ...ListDividedComponents, + ...MenuComponents, + Loader, + TableWrapper + } + + plugin.globalComponents = { + ...plugin.globalComponents, + ...components + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/wallet-tabs-setup.js b/src/renderer/services/plugin-manager/setup/wallet-tabs-setup.js new file mode 100644 index 0000000000..8b212858d3 --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/wallet-tabs-setup.js @@ -0,0 +1,26 @@ +import { normalizeJson } from '../utils/normalize-json' +import isString from 'lodash/isString' + +export function createWalletTabsSetup (plugin, pluginObject, sandbox, profileId) { + return async () => { + const pluginWalletTabs = normalizeJson(pluginObject.getWalletTabs()) + + if (pluginWalletTabs && Array.isArray(pluginWalletTabs) && pluginWalletTabs.length) { + // Validate the configuration of each tab + const walletTabs = pluginWalletTabs.reduce((valid, walletTab) => { + if (isString(walletTab.tabTitle) && plugin.components[walletTab.componentName]) { + valid.push(walletTab) + } + return valid + }, []) + + if (walletTabs.length) { + await sandbox.app.$store.dispatch('plugin/setWalletTabs', { + pluginId: plugin.config.id, + walletTabs, + profileId + }) + } + } + } +} diff --git a/src/renderer/services/plugin-manager/setup/webframe-setup.js b/src/renderer/services/plugin-manager/setup/webframe-setup.js new file mode 100644 index 0000000000..04082de507 --- /dev/null +++ b/src/renderer/services/plugin-manager/setup/webframe-setup.js @@ -0,0 +1,7 @@ +import WebFrame from '@/components/utils/WebFrame' + +export function createWebFrameSetup (plugin) { + return () => { + plugin.globalComponents[WebFrame.name] = WebFrame + } +} diff --git a/src/renderer/services/plugin-manager/utils/get-all-routes.js b/src/renderer/services/plugin-manager/utils/get-all-routes.js new file mode 100644 index 0000000000..558261f81c --- /dev/null +++ b/src/renderer/services/plugin-manager/utils/get-all-routes.js @@ -0,0 +1,4 @@ +export function getAllRoutes (app, plugin) { + const routes = plugin ? plugin.routes : [] + return [...app.$router.options.routes, ...routes] +} diff --git a/src/renderer/services/plugin-manager/utils/normalize-json.js b/src/renderer/services/plugin-manager/utils/normalize-json.js new file mode 100644 index 0000000000..cf92596705 --- /dev/null +++ b/src/renderer/services/plugin-manager/utils/normalize-json.js @@ -0,0 +1,3 @@ +export function normalizeJson (data) { + return JSON.parse(JSON.stringify(data)) +} diff --git a/src/renderer/services/plugin-manager/utils/validate-plugin-path.js b/src/renderer/services/plugin-manager/utils/validate-plugin-path.js new file mode 100644 index 0000000000..b68ead4281 --- /dev/null +++ b/src/renderer/services/plugin-manager/utils/validate-plugin-path.js @@ -0,0 +1,16 @@ +import path from 'path' +import fs from 'fs' + +export function validatePluginPath (pluginPath) { + const structureExists = [ + 'package.json', + 'src', + 'src/index.js' + ] + + for (const pathCheck of structureExists) { + if (!fs.existsSync(path.resolve(pluginPath, pathCheck))) { + throw new Error(`'${pathCheck}' does not exist`) + } + } +} diff --git a/src/renderer/services/plugin-manager/wallet-components.js b/src/renderer/services/plugin-manager/wallet-components.js deleted file mode 100644 index 639fdc0469..0000000000 --- a/src/renderer/services/plugin-manager/wallet-components.js +++ /dev/null @@ -1,30 +0,0 @@ -import * as ButtonComponents from '@/components/Button' -import * as CollapseComponents from '@/components/Collapse' -import * as InputComponents from '@/components/Input' -import * as ListDividedComponents from '@/components/ListDivided' -import * as MenuComponents from '@/components/Menu' -import Loader from '@/components/utils/Loader' -import TableWrapper from '@/components/utils/TableWrapper' -import WebFrame from '@/components/utils/WebFrame' - -export default (permissions) => { - let components = {} - - if (permissions.includes('UI_COMPONENTS')) { - components = { - Button: ButtonComponents, - Collapse: CollapseComponents, - Input: InputComponents, - ListDivided: ListDividedComponents, - Loader, - Menu: MenuComponents, - TableWrapper - } - } - - if (permissions.includes('WEBFRAME')) { - components = { ...components, WebFrame } - } - - return components -} diff --git a/src/renderer/store/modules/plugin.js b/src/renderer/store/modules/plugin.js index dc4289d576..d85bd8ab89 100644 --- a/src/renderer/store/modules/plugin.js +++ b/src/renderer/store/modules/plugin.js @@ -225,7 +225,11 @@ export default { return } - for (const pluginId of Object.keys(state.enabled[profile.id])) { + for (const pluginId in state.enabled[profile.id]) { + if (!getters.isEnabled(pluginId)) { + continue + } + if (!getters.isAvailable(pluginId)) { continue }