-
Notifications
You must be signed in to change notification settings - Fork 205
fix(web-app-external): enable Collabora Save As / export to storage #13906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| Enhancement: Save a copy of office documents to another format (Collabora) | ||
|
|
||
| Office documents opened in Collabora Online could be renamed but not saved as a | ||
| copy or exported to another format back into oCIS storage: "Save As" silently | ||
| did nothing. Collabora exposes "Save As" as a host-delegated operation - it | ||
| posts a `UI_SaveAs` message and waits for the integration to reply with the | ||
| target filename via `Action_SaveAs` - but the app-provider integration never | ||
| opened that postMessage channel. | ||
|
|
||
| The app-provider now announces itself to the editor with `Host_PostmessageReady` | ||
| once the iframe has loaded, requests the grouped Save-As control via | ||
| `ui_defaults` (`SaveAsMode=group`), and on `UI_SaveAs` prompts for the copy's | ||
| name and replies with `Action_SaveAs`. The collaboration service already | ||
| implements WOPI `PutRelativeFile`, so the copy is written into the same space, | ||
| respecting the user's permissions. The chosen file extension selects the export | ||
| format (e.g. docx to pdf/odt, xlsx to ods, pptx to pdf). | ||
|
|
||
| https://github.com/owncloud/web/pull/13906 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,13 @@ | ||
| <template> | ||
| <iframe | ||
| v-if="appUrl && method === 'GET'" | ||
| ref="appIframe" | ||
| :src="appUrl" | ||
| class="oc-width-1-1 oc-height-1-1" | ||
| :title="iFrameTitle" | ||
| allowfullscreen | ||
| allow="camera; clipboard-read; clipboard-write" | ||
| @load="onIframeLoad" | ||
| /> | ||
| <div v-if="appUrl && method === 'POST' && formParameters" class="oc-height-1-1 oc-width-1-1"> | ||
| <form :action="appUrl" target="app-iframe" method="post"> | ||
|
|
@@ -15,19 +17,32 @@ | |
| </div> | ||
| </form> | ||
| <iframe | ||
| ref="appIframe" | ||
| name="app-iframe" | ||
| :src="appUrl" | ||
| class="oc-width-1-1 oc-height-1-1" | ||
| :title="iFrameTitle" | ||
| allowfullscreen | ||
| allow="camera; clipboard-read; clipboard-write" | ||
| @load="onIframeLoad" | ||
| /> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script lang="ts" setup> | ||
| import { stringify } from 'qs' | ||
| import { computed, inject, unref, nextTick, ref, watch, VNodeRef, onMounted, type Ref } from 'vue' | ||
| import { | ||
| computed, | ||
| inject, | ||
| unref, | ||
| nextTick, | ||
| ref, | ||
| watch, | ||
| VNodeRef, | ||
| onMounted, | ||
| onBeforeUnmount, | ||
| type Ref | ||
| } from 'vue' | ||
| import { useTask } from 'vue-concurrency' | ||
| import { useGettext } from 'vue3-gettext' | ||
|
|
||
|
|
@@ -38,6 +53,7 @@ import { | |
| useCapabilityStore, | ||
| useConfigStore, | ||
| useMessages, | ||
| useModals, | ||
| useRequest, | ||
| useAppProviderService, | ||
| useRoute, | ||
|
|
@@ -67,6 +83,7 @@ const props = defineProps<Props>() | |
| const language = useGettext() | ||
| const { $gettext } = language | ||
| const { showErrorMessage } = useMessages() | ||
| const { dispatchModal } = useModals() | ||
| const capabilityStore = useCapabilityStore() | ||
| const configStore = useConfigStore() | ||
| const route = useRoute() | ||
|
|
@@ -97,6 +114,68 @@ const appUrl = ref() | |
| const formParameters = ref({}) | ||
| const method = ref() | ||
| const subm: VNodeRef = ref() | ||
| const appIframe = ref<HTMLIFrameElement>() | ||
|
|
||
| // Origin of the editor (e.g. the Collabora host) used as the target origin when | ||
| // posting messages into the iframe. | ||
| const appOrigin = computed(() => { | ||
| try { | ||
| return new URL(unref(appUrl)).origin | ||
| } catch { | ||
| return '' | ||
| } | ||
| }) | ||
|
|
||
| // Collabora exposes "Save As" (export to another format, saved back to storage | ||
| // via WOPI PutRelativeFile) as a host-delegated operation: it shows a grouped | ||
| // Save-As control and posts a `UI_SaveAs` message, expecting the host to reply | ||
| // with the target filename. Request that grouped control via `ui_defaults`. | ||
| // The parameter is Collabora-specific and ignored by other app providers. | ||
| const withSaveAsUiDefaults = (rawUrl: string) => { | ||
| try { | ||
| const url = new URL(rawUrl) | ||
| if (!url.searchParams.has('ui_defaults')) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (CONFIRMED): const existing = url.searchParams.get('ui_defaults')
url.searchParams.set(
'ui_defaults',
existing ? `${existing};SaveAsMode=group` : 'SaveAsMode=group'
)With the current code the Save As feature is silently unavailable for a significant class of Collabora deployments, with no error or UI indication. |
||
| url.searchParams.set('ui_defaults', 'SaveAsMode=group') | ||
| } | ||
| return url.href | ||
| } catch { | ||
| return rawUrl | ||
| } | ||
| } | ||
|
|
||
| // Post a WOPI postMessage into the editor iframe (messages are JSON strings). | ||
| const postToApp = (message: Record<string, unknown>) => { | ||
| const target = unref(appIframe)?.contentWindow | ||
| if (!target || !unref(appOrigin)) { | ||
| return | ||
| } | ||
| target.postMessage(JSON.stringify({ ...message, SendTime: Date.now() }), unref(appOrigin)) | ||
| } | ||
|
|
||
| // The editor only emits its rich postMessage API (UI_SaveAs, App_LoadingStatus, | ||
| // ...) once the host has announced itself with `Host_PostmessageReady`. Without | ||
| // this handshake "Save As" silently does nothing. Send it as soon as the iframe | ||
| // has loaded. | ||
| const onIframeLoad = () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Design issue (CONFIRMED): Both |
||
| postToApp({ MessageId: 'Host_PostmessageReady', Values: {} }) | ||
| } | ||
|
|
||
| // Handle the editor's "Save As" request: ask the user for the copy's name (the | ||
| // extension selects the export format) and reply with `Action_SaveAs`, which | ||
| // makes the editor render to that format and PutRelativeFile it into the space. | ||
| const onSaveAs = () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (CONFIRMED): Add the guard at the top of if (props.isReadOnly) {
showErrorMessage({ title: $gettext('Cannot save a copy: file is read-only') })
return
} |
||
| dispatchModal({ | ||
| variation: 'passive', | ||
| title: $gettext('Save a copy'), | ||
| confirmText: $gettext('Save'), | ||
| hasInput: true, | ||
| inputValue: props.resource.name, | ||
| inputLabel: $gettext('File name'), | ||
| onConfirm: (filename: string) => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plausible hazard (two related issues):
For (1): |
||
| postToApp({ MessageId: 'Action_SaveAs', Values: { Filename: filename, Notify: true } }) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| const iFrameTitle = computed(() => { | ||
| return $gettext('"%{appName}" app content area', { | ||
|
|
@@ -157,7 +236,7 @@ const loadAppUrl = useTask(function* (signal, viewMode: string) { | |
| throw new Error('Error in app server response') | ||
| } | ||
|
|
||
| appUrl.value = response.data.app_url | ||
| appUrl.value = withSaveAsUiDefaults(response.data.app_url) | ||
| method.value = response.data.method | ||
|
|
||
| if (response.data.form_parameters) { | ||
|
|
@@ -179,20 +258,35 @@ const determineOpenAsPreview = (appName: string) => { | |
| return openAsPreview === true || (Array.isArray(openAsPreview) && openAsPreview.includes(appName)) | ||
| } | ||
|
|
||
| // switch to write mode when edit is clicked | ||
| const catchClickMicrosoftEdit = (event: MessageEvent) => { | ||
| // Single handler for the editor's postMessage events. Only messages coming from | ||
| // the editor's own origin are accepted. | ||
| const onAppMessage = (event: MessageEvent) => { | ||
| if (unref(appOrigin) && event.origin !== unref(appOrigin)) { | ||
| return | ||
| } | ||
| let message: { MessageId?: string } | ||
| try { | ||
| if (JSON.parse(event.data)?.MessageId === 'UI_Edit') { | ||
| loadAppUrl.perform('write') | ||
| } | ||
| } catch {} | ||
| message = JSON.parse(event.data) | ||
| } catch { | ||
| return | ||
| } | ||
| switch (message?.MessageId) { | ||
| case 'UI_Edit': | ||
| // switch to write mode when edit is clicked | ||
| if (determineOpenAsPreview(unref(appName))) { | ||
| loadAppUrl.perform('write') | ||
| } | ||
| break | ||
| case 'UI_SaveAs': | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plausible hazard: No re-entrancy guard on |
||
| onSaveAs() | ||
| break | ||
| } | ||
| } | ||
| onMounted(() => { | ||
| if (determineOpenAsPreview(unref(appName))) { | ||
| window.addEventListener('message', catchClickMicrosoftEdit) | ||
| } else { | ||
| window.removeEventListener('message', catchClickMicrosoftEdit) | ||
| } | ||
| window.addEventListener('message', onAppMessage) | ||
| }) | ||
| onBeforeUnmount(() => { | ||
| window.removeEventListener('message', onAppMessage) | ||
| }) | ||
|
|
||
| watch( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Plausible hazard:
appOriginis''for relativeapp_urlvalues (theURLconstructor throws for relative paths with no base, hitting thecatchblock). The origin guard at line 296 then drops all postMessages — includingUI_Edit— for the entire session. Any deployment where the WOPI server returns a relativeapp_urlwould silently break the Edit-button flow. Thetry/catchinwithSaveAsUiDefaults(line 135) already handles this for the URL transform, butappOriginhas no fallback for the same case.