From 519d33e6de7feb2e0bd065829b34a1833f786e81 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 17 Nov 2023 11:29:12 +0100 Subject: [PATCH] feat: use postMessage in embed mode instead of custom events --- .../unreleased/enhancement-embed-mode-actions | 1 + .../unreleased/enhancement-location-picker | 1 + docs/embed-mode/_index.md | 36 +++-- .../components/EmbedActions/EmbedActions.vue | 18 +-- .../EmbedActions/EmbedActions.spec.ts | 132 ++++++++++++++---- .../unit/views/spaces/GenericSpace.spec.ts | 6 +- .../src/composables/embedMode/useEmbedMode.ts | 20 ++- packages/web-pkg/src/configuration/types.ts | 7 +- .../FilesList/ResourceTable.spec.ts | 4 +- .../web-runtime/src/container/bootstrap.ts | 9 +- packages/web-runtime/src/store/config.ts | 7 +- .../unit/components/Topbar/TopBar.spec.ts | 4 +- 12 files changed, 179 insertions(+), 66 deletions(-) diff --git a/changelog/unreleased/enhancement-embed-mode-actions b/changelog/unreleased/enhancement-embed-mode-actions index a620b9cf9ee..49cf23d2401 100644 --- a/changelog/unreleased/enhancement-embed-mode-actions +++ b/changelog/unreleased/enhancement-embed-mode-actions @@ -3,4 +3,5 @@ Enhancement: Add embed mode actions We've added three new actions available in the embed mode. These actions are "Share", "Select" and "Share". They are emitting events with an optional payload. For more information, check the documentation. https://github.com/owncloud/web/pull/9841 +https://github.com/owncloud/web/pull/9981 https://github.com/owncloud/web/issues/9768 diff --git a/changelog/unreleased/enhancement-location-picker b/changelog/unreleased/enhancement-location-picker index 2e4ee30d28c..5302796c9c7 100644 --- a/changelog/unreleased/enhancement-location-picker +++ b/changelog/unreleased/enhancement-location-picker @@ -4,4 +4,5 @@ We've added a new query param called `embed-target` which can have value `locati When the value is set to `location`, it allows selecting the `currentFolder` as location instead of selecting resources. https://github.com/owncloud/web/pull/9863 +https://github.com/owncloud/web/pull/9981 https://github.com/owncloud/web/issues/9768 diff --git a/docs/embed-mode/_index.md b/docs/embed-mode/_index.md index d47af7d8461..576e7f066ef 100644 --- a/docs/embed-mode/_index.md +++ b/docs/embed-mode/_index.md @@ -20,15 +20,23 @@ To integrate ownCloud Web into your application, add an iframe element pointing ``` -## Events +## Communication -The app is emitting various events depending on the goal of the user. All events are prefixed with `owncloud-embed:` to prevent any naming conflicts with other events. +To establish seamless cross-origin communication between the embedded instance and the parent application, our approach involves emitting events using the `postMessage` method. These events can be conveniently captured by utilizing the standard `window.addEventListener('message', listener)` pattern. -| Event name | Payload | Description | +### Target origin + +By default, the `postMessage` method has its `targetOrigin` parameter set to `*`. However, this configuration is not considered sufficiently secure. It is recommended best practice to explicitly pass in the URI of the iframe origin (not the parent application). To enhance security, you can specify this value by modifying the config option `options.embed.messagesOrigin`. + +### Events + +To maintain uniformity and ease of handling, each event encapsulates the same structure within its payload: `{ name: string, data: any }`. + +| Name | Data | Description | | --- | --- | --- | -| **owncloud-embed:select** | Resource[] | Gets emitted when user selects resources via the "Attach as copy" action | +| **owncloud-embed:select** | Resource[] | Gets emitted when user selects resources or location via the select action | | **owncloud-embed:share** | string[] | Gets emitted when user selects resources and shares them via the "Share links" action | -| **owncloud-embed:cancel** | void | Gets emitted when user attempts to close the embedded instance via "Cancel" action | +| **owncloud-embed:cancel** | null | Gets emitted when user attempts to close the embedded instance via "Cancel" action | ### Example @@ -37,12 +45,16 @@ The app is emitting various events depending on the goal of the user. All events ``` @@ -57,11 +69,15 @@ By default, the Embed mode allows users to select resources. In certain cases (e ``` diff --git a/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue b/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue index 0c8461b1a7a..32f18195679 100644 --- a/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue +++ b/packages/web-app-files/src/components/EmbedActions/EmbedActions.vue @@ -45,7 +45,7 @@ export default { const clientService = useClientService() const passwordPolicyService = usePasswordPolicyService() const language = useGettext() - const { isLocationPicker } = useEmbedMode() + const { isLocationPicker, postMessage } = useEmbedMode() const selectedFiles = computed(() => { if (isLocationPicker.value) { @@ -64,27 +64,17 @@ export default { ) const emitSelect = (): void => { - const event: CustomEvent = new CustomEvent('owncloud-embed:select', { - detail: selectedFiles.value - }) - - window.parent.dispatchEvent(event) + postMessage('owncloud-embed:select', selectedFiles.value) } const emitCancel = (): void => { - const event: CustomEvent = new CustomEvent('owncloud-embed:cancel') - - window.parent.dispatchEvent(event) + postMessage('owncloud-embed:cancel', null) } const emitShare = (links: string[]): void => { if (!canCreatePublicLinks.value) return - const event: CustomEvent = new CustomEvent('owncloud-embed:share', { - detail: links - }) - - window.parent.dispatchEvent(event) + postMessage('owncloud-embed:share', links) } const sharePublicLinks = async (): Promise => { diff --git a/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts b/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts index 5a530ad0b74..68a6e1bf19d 100644 --- a/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts +++ b/packages/web-app-files/tests/unit/components/EmbedActions/EmbedActions.spec.ts @@ -39,56 +39,107 @@ describe('EmbedActions', () => { }) it('should emit select event when the select action is triggered', async () => { - window.parent.dispatchEvent = jest.fn() + window.parent.postMessage = jest.fn() global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] }) await wrapper.find(selectors.btnSelect).trigger('click') - expect(window.parent.dispatchEvent).toHaveBeenCalledWith({ - name: 'owncloud-embed:select', - payload: { detail: [{ id: 1 }] } - }) + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:select', + data: [{ id: 1 }] + }, + {} + ) }) it('should enable select action when embedTarget is set to location', () => { - const { wrapper } = getWrapper({ configuration: { options: { embedTarget: 'location' } } }) + const { wrapper } = getWrapper({ + configuration: { options: { embed: { target: 'location' } } } + }) expect(wrapper.find(selectors.btnSelect).attributes()).not.toHaveProperty('disabled') }) it('should emit select event with currentFolder as selected resource when select action is triggered', async () => { - window.parent.dispatchEvent = jest.fn() + window.parent.postMessage = jest.fn() global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) const { wrapper } = getWrapper({ currentFolder: { id: 1 }, - configuration: { options: { embedTarget: 'location' } } + configuration: { options: { embed: { target: 'location' } } } }) await wrapper.find(selectors.btnSelect).trigger('click') - expect(window.parent.dispatchEvent).toHaveBeenCalledWith({ - name: 'owncloud-embed:select', - payload: { detail: [{ id: 1 }] } + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:select', + data: [{ id: 1 }] + }, + {} + ) + }) + + it('should specify the targetOrigin when it is set in the config', async () => { + window.parent.postMessage = jest.fn() + global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) + + const { wrapper } = getWrapper({ + selectedFiles: [{ id: 1 }], + configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } } }) + + await wrapper.find(selectors.btnSelect).trigger('click') + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:select', + data: [{ id: 1 }] + }, + { targetOrigin: 'https://example.org' } + ) }) }) describe('cancel action', () => { it('should emit cancel event when the cancel action is triggered', async () => { - window.parent.dispatchEvent = jest.fn() + window.parent.postMessage = jest.fn() global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) const { wrapper } = getWrapper({ selectedFiles: [{ id: 1 }] }) await wrapper.find(selectors.btnCancel).trigger('click') - expect(window.parent.dispatchEvent).toHaveBeenCalledWith({ - name: 'owncloud-embed:cancel', - payload: undefined + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:cancel', + data: null + }, + {} + ) + }) + + it('should specify the targetOrigin when it is set in the config', async () => { + window.parent.postMessage = jest.fn() + global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) + + const { wrapper } = getWrapper({ + selectedFiles: [{ id: 1 }], + configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } } }) + + await wrapper.find(selectors.btnCancel).trigger('click') + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:cancel', + data: null + }, + { targetOrigin: 'https://example.org' } + ) }) }) @@ -115,7 +166,7 @@ describe('EmbedActions', () => { }) it('should emit share event when share action is triggered', async () => { - window.parent.dispatchEvent = jest.fn() + window.parent.postMessage = jest.fn() global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) const { wrapper } = getWrapper({ @@ -125,14 +176,17 @@ describe('EmbedActions', () => { await wrapper.find(selectors.btnShare).trigger('click') - expect(window.parent.dispatchEvent).toHaveBeenCalledWith({ - name: 'owncloud-embed:share', - payload: { detail: ['link-1'] } - }) + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:share', + data: ['link-1'] + }, + {} + ) }) it('should ask for password first when required when share action is triggered', async () => { - window.parent.dispatchEvent = jest.fn() + window.parent.postMessage = jest.fn() global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) const { wrapper } = getWrapper({ @@ -145,17 +199,43 @@ describe('EmbedActions', () => { await wrapper.find(selectors.btnShare).trigger('click') - expect(window.parent.dispatchEvent).toHaveBeenCalledWith({ - name: 'owncloud-embed:share', - payload: { detail: ['password-link-1'] } - }) + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:share', + data: ['password-link-1'] + }, + {} + ) }) it('should hide share action when embedTarget is set to location', () => { - const { wrapper } = getWrapper({ configuration: { options: { embedTarget: 'location' } } }) + const { wrapper } = getWrapper({ + configuration: { options: { embed: { target: 'location' } } } + }) expect(wrapper.find(selectors.btnShare).exists()).toBe(false) }) + + it('should specify the targetOrigin when it is set in the config', async () => { + window.parent.postMessage = jest.fn() + global.CustomEvent = jest.fn().mockImplementation(mockCustomEvent) + + const { wrapper } = getWrapper({ + selectedFiles: [{ id: 1 }], + configuration: { options: { embed: { messagesOrigin: 'https://example.org' } } }, + abilities: [{ action: 'create-all', subject: 'PublicLink' }] + }) + + await wrapper.find(selectors.btnShare).trigger('click') + + expect(window.parent.postMessage).toHaveBeenCalledWith( + { + name: 'owncloud-embed:share', + data: ['link-1'] + }, + { targetOrigin: 'https://example.org' } + ) + }) }) }) diff --git a/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts b/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts index 8fcb16a5f6e..0e6239a08ea 100644 --- a/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts +++ b/packages/web-app-files/tests/unit/views/spaces/GenericSpace.spec.ts @@ -233,7 +233,7 @@ describe('GenericSpace view', () => { it('should render create folder button when in embed mode', () => { const { wrapper } = getMountedWrapper({ stubs: { 'app-bar': AppBarStub, CreateAndUpload: true }, - configurationOptions: { mode: 'embed' } + configurationOptions: { embed: { enabled: true } } }) expect(wrapper.find(selectors.btnCreateFolder).exists()).toBe(true) @@ -242,7 +242,7 @@ describe('GenericSpace view', () => { it('should not render create and upload actions when in embed mode', () => { const { wrapper } = getMountedWrapper({ stubs: { 'app-bar': AppBarStub, CreateAndUpload: true }, - configurationOptions: { mode: 'embed' } + configurationOptions: { embed: { enabled: true } } }) expect(wrapper.find(selectors.actionsCreateAndUpload).exists()).toBe(false) @@ -251,7 +251,7 @@ describe('GenericSpace view', () => { it('should call createNewFolderAction when create folder button is clicked', () => { const { wrapper } = getMountedWrapper({ stubs: { 'app-bar': AppBarStub, CreateAndUpload: true }, - configurationOptions: { mode: 'embed' } + configurationOptions: { embed: { enabled: true } } }) // @ts-expect-error even though the vm object is not specified on WrapperLike, it actually is present there diff --git a/packages/web-pkg/src/composables/embedMode/useEmbedMode.ts b/packages/web-pkg/src/composables/embedMode/useEmbedMode.ts index 292391dc37b..e1fd3ee0f14 100644 --- a/packages/web-pkg/src/composables/embedMode/useEmbedMode.ts +++ b/packages/web-pkg/src/composables/embedMode/useEmbedMode.ts @@ -4,11 +4,25 @@ import { useStore } from '../store' export const useEmbedMode = () => { const store = useStore() - const isEnabled = computed(() => store.getters.configuration.options.mode === 'embed') + const isEnabled = computed(() => store.getters.configuration.options.embed?.enabled) const isLocationPicker = computed(() => { - return store.getters.configuration.options.embedTarget === 'location' + return store.getters.configuration.options.embed?.target === 'location' }) - return { isEnabled, isLocationPicker } + const messagesTargetOrigin = computed( + () => store.getters.configuration.options.embed?.messagesOrigin + ) + + const postMessage = (name: string, data?: Payload): void => { + const options: WindowPostMessageOptions = {} + + if (messagesTargetOrigin.value) { + options.targetOrigin = messagesTargetOrigin.value + } + + window.parent.postMessage({ name, data }, options) + } + + return { isEnabled, isLocationPicker, messagesTargetOrigin, postMessage } } diff --git a/packages/web-pkg/src/configuration/types.ts b/packages/web-pkg/src/configuration/types.ts index 48e866b19ff..27c242412de 100644 --- a/packages/web-pkg/src/configuration/types.ts +++ b/packages/web-pkg/src/configuration/types.ts @@ -26,15 +26,18 @@ export interface OptionsConfiguration { openLinksWithDefaultApp?: boolean tokenStorageLocal?: boolean disabledExtensions?: string[] - mode?: string isRunningOnEos?: boolean - embedTarget?: string editor?: { openAsPreview?: boolean | string[] } ocm?: { openRemotely?: boolean } + embed?: { + enabled?: boolean + target?: string + messagesOrigin?: string + } } export interface OAuth2Configuration { diff --git a/packages/web-pkg/tests/unit/components/FilesList/ResourceTable.spec.ts b/packages/web-pkg/tests/unit/components/FilesList/ResourceTable.spec.ts index 095351839ca..3806a52fb91 100644 --- a/packages/web-pkg/tests/unit/components/FilesList/ResourceTable.spec.ts +++ b/packages/web-pkg/tests/unit/components/FilesList/ResourceTable.spec.ts @@ -272,7 +272,7 @@ describe('ResourceTable', () => { describe('embed mode location target', () => { it('should not hide checkboxes when embed mode does not have location as target', () => { const { wrapper } = getMountedWrapper({ - configuration: { options: { embedTarget: undefined } } + configuration: { options: { embed: { target: undefined } } } }) expect(wrapper.find('.resource-table-select-all').exists()).toBe(true) @@ -281,7 +281,7 @@ describe('ResourceTable', () => { it('should hide checkboxes when embed mode has location as target', () => { const { wrapper } = getMountedWrapper({ - configuration: { options: { embedTarget: 'location' } } + configuration: { options: { embed: { target: 'location' } } } }) expect(wrapper.find('.resource-table-select-all').exists()).toBe(false) diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index cf237f31153..bc08b07380c 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -51,15 +51,18 @@ export const announceConfiguration = async (path: string): Promise { 'should hide %s when mode is "embed"', (componentName) => { const { wrapper } = getWrapper({ - configuration: { options: { disableFeedbackLink: false, mode: 'embed' } } + configuration: { options: { disableFeedbackLink: false, embed: { enabled: true } } } }) expect(wrapper.find(`${componentName}-stub`).exists()).toBeFalsy() } @@ -54,7 +54,7 @@ describe('Top Bar component', () => { 'should not hide %s when mode is not "embed"', (componentName) => { const { wrapper } = getWrapper({ - configuration: { options: { disableFeedbackLink: false, mode: 'web' } }, + configuration: { options: { disableFeedbackLink: false, embed: { enabled: false } } }, capabilities: { notifications: { 'ocs-endpoints': ['list', 'get', 'delete'] } }