Skip to content

Commit

Permalink
feat!: use postMessage in embed mode instead of custom events
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasHirt committed Nov 17, 2023
1 parent 263ea12 commit f033b4d
Show file tree
Hide file tree
Showing 10 changed files with 177 additions and 57 deletions.
7 changes: 7 additions & 0 deletions changelog/unreleased/change-embed-actions-post-message
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Change: Use postMessage method instead of emitting custom event in embed mode

We have switched to using `postMessage` method in the embed mode instead of emitting custom events.
This mitigates the issue when running the embed mode on different origin blocked the access to the `dispatchEvent` method.
To allow secure usage of this method, we have introduced a new config option `options.embed.messagesOrigin` which can be set to the URI of the iframe origin.

https://github.com/owncloud/web/pull/9981
6 changes: 6 additions & 0 deletions changelog/unreleased/change-group-embed-specific-config
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Change: Group Embed mode specific config options

We've grouped any Embed mode specific config options into a new `embed` config option.
This means that the previously available `options.embedTarget` option is now `options.embed.target`.

https://github.com/owncloud/web/pull/9981
36 changes: 26 additions & 10 deletions docs/embed-mode/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,23 @@ To integrate ownCloud Web into your application, add an iframe element pointing
<iframe src="<web-url>?mode=embed"></iframe>
```

## 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

Expand All @@ -37,12 +45,16 @@ The app is emitting various events depending on the goal of the user. All events

<script>
function selectEventHandler(event) {
const resources = event.detail
if (event.data?.name !== 'owncloud-embed:select') {
return
}
const resources = event.data.data
doSomethingWithSelectedResources(resources)
}
window.addEventListener('owncloud-embed:select', selectEventHandler)
window.addEventListener('message', selectEventHandler)
</script>
```
Expand All @@ -57,11 +69,15 @@ By default, the Embed mode allows users to select resources. In certain cases (e
<script>
function selectEventHandler(event) {
const currentFolder = event.detail[0]
if (event.data?.name !== 'owncloud-embed:select') {
return
}
const resources = event.data.data[0]
uploadIntoCurrentFolder(currentFolder)
doSomethingWithSelectedResources(resources)
}
window.addEventListener('owncloud-embed:select', selectEventHandler)
window.addEventListener('message', selectEventHandler)
</script>
```
27 changes: 13 additions & 14 deletions packages/web-app-files/src/components/EmbedActions/EmbedActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default {
const clientService = useClientService()
const passwordPolicyService = usePasswordPolicyService()
const language = useGettext()
const { isLocationPicker } = useEmbedMode()
const { isLocationPicker, messagesTargetOrigin } = useEmbedMode()
const selectedFiles = computed<Resource[]>(() => {
if (isLocationPicker.value) {
Expand All @@ -64,27 +64,26 @@ export default {
)
const emitSelect = (): void => {
const event: CustomEvent<Resource[]> = new CustomEvent('owncloud-embed:select', {
detail: selectedFiles.value
})
window.parent.dispatchEvent(event)
window.parent.postMessage(
{ name: 'owncloud-embed:select', data: selectedFiles.value },
messagesTargetOrigin.value
)
}
const emitCancel = (): void => {
const event: CustomEvent<void> = new CustomEvent('owncloud-embed:cancel')
window.parent.dispatchEvent(event)
window.parent.postMessage(
{ name: 'owncloud-embed:cancel', data: null },
messagesTargetOrigin.value
)
}
const emitShare = (links: string[]): void => {
if (!canCreatePublicLinks.value) return
const event: CustomEvent<string[]> = new CustomEvent('owncloud-embed:share', {
detail: links
})
window.parent.dispatchEvent(event)
window.parent.postMessage(
{ name: 'owncloud-embed:share', data: links },
messagesTargetOrigin.value
)
}
const sharePublicLinks = async (): Promise<string[]> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]
},
'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
},
'https://example.org'
)
})
})

Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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']
},
'https://example.org'
)
})
})
})

Expand Down
8 changes: 6 additions & 2 deletions packages/web-pkg/src/composables/embedMode/useEmbedMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ export const useEmbedMode = () => {
const isEnabled = computed<boolean>(() => store.getters.configuration.options.mode === 'embed')

const isLocationPicker = computed<boolean>(() => {
return store.getters.configuration.options.embedTarget === 'location'
return store.getters.configuration.options.embed?.target === 'location'
})

return { isEnabled, isLocationPicker }
const messagesTargetOrigin = computed<string>(
() => store.getters.configuration.options.embed?.messagesOrigin || '*'
)

return { isEnabled, isLocationPicker, messagesTargetOrigin }
}
5 changes: 4 additions & 1 deletion packages/web-pkg/src/configuration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ export interface OptionsConfiguration {
disabledExtensions?: string[]
mode?: string
isRunningOnEos?: boolean
embedTarget?: string
editor?: {
openAsPreview?: boolean | string[]
}
ocm?: {
openRemotely?: boolean
}
embed?: {
target?: string
messagesOrigin?: string
}
}

export interface OAuth2Configuration {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/web-runtime/src/container/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const announceConfiguration = async (path: string): Promise<RuntimeConfig
const embedTarget = getQueryParam('embed-target')

if (embedTarget) {
rawConfig.options.embedTarget = embedTarget
rawConfig.options.embed = { ...rawConfig.options.embed, target: embedTarget }
}

configurationManager.initialize(rawConfig)
Expand Down
Loading

0 comments on commit f033b4d

Please sign in to comment.