Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions changelog/unreleased/enhancement-collabora-save-as.md
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
120 changes: 107 additions & 13 deletions packages/web-app-external/src/App.vue
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">
Expand All @@ -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'

Expand All @@ -38,6 +53,7 @@ import {
useCapabilityStore,
useConfigStore,
useMessages,
useModals,
useRequest,
useAppProviderService,
useRoute,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plausible hazard: appOrigin is '' for relative app_url values (the URL constructor throws for relative paths with no base, hitting the catch block). The origin guard at line 296 then drops all postMessages — including UI_Edit — for the entire session. Any deployment where the WOPI server returns a relative app_url would silently break the Edit-button flow. The try/catch in withSaveAsUiDefaults (line 135) already handles this for the URL transform, but appOrigin has no fallback for the same case.

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')) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (CONFIRMED): url.searchParams.has('ui_defaults') returns true for any deployment where the server already sets ui_defaults (e.g. UIMode=notebookbar), so the if body is skipped and SaveAsMode=group is never added. Collabora's ui_defaults is a semicolon-separated list, not a single value — the correct fix is to append rather than set:

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 = () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design issue (CONFIRMED): onIframeLoad posts Host_PostmessageReady for every WOPI provider, not just Collabora. Combined with line 264 unconditionally calling withSaveAsUiDefaults on every provider's app_url, all non-Collabora WOPI editors receive an unsolicited Host_PostmessageReady message and have ui_defaults=SaveAsMode=group appended to their URLs on every load. While most providers ignore both, any future provider that interprets UI_SaveAs differently will incorrectly trigger the Collabora Save-As modal flow.

Both withSaveAsUiDefaults and the Host_PostmessageReady send should be gated on the provider being Collabora (e.g. checking appName or a capability flag). The inline comment on line 133 (// The parameter is Collabora-specific) acknowledges this but doesn't enforce it in code.

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 = () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (CONFIRMED): onSaveAs has no isReadOnly guard, whereas loadAppUrl (line 221) already short-circuits with a user-visible error for read-only sessions. When isReadOnly is true, withSaveAsUiDefaults still adds SaveAsMode=group to the URL (see line 264), so Collabora may still render the Save-As control and emit UI_SaveAs. The user completes the modal, Action_SaveAs is posted fire-and-forget, the WOPI server rejects PutRelativeFile, the modal closes normally, and the user sees no error while no copy is created.

Add the guard at the top of onSaveAs:

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) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plausible hazard (two related issues):

  1. Silent no-op on null iframe: postToApp returns undefined without error when appIframe.contentWindow is null (line 149). onConfirm doesn't inspect the return value, and ModalWrapper unconditionally closes the dialog after onConfirm resolves. If loadAppUrl restarts (e.g. triggered by the watch at line 324) between UI_SaveAs receipt and the user pressing Save, appIframe can be transiently null — Action_SaveAs is never sent, but the user sees the modal close normally and believes the copy was saved.

  2. Reactive-ref stale closure: onConfirm reads appOrigin and appIframe at the moment the user presses Save (not at modal-dispatch time). In a SPA where the component is reused across resources without remounting, a resource-prop change while the modal is open replaces both refs with values for the new document. The user's Save press then dispatches Action_SaveAs to the wrong iframe with the filename typed for the old document.

For (1): postToApp could return a boolean indicating success, and onConfirm could show an error rather than silently closing.

postToApp({ MessageId: 'Action_SaveAs', Values: { Filename: filename, Notify: true } })
}
})
}

const iFrameTitle = computed(() => {
return $gettext('"%{appName}" app content area', {
Expand Down Expand Up @@ -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) {
Expand All @@ -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':

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plausible hazard: No re-entrancy guard on UI_SaveAs. If Collabora emits it twice in quick succession (e.g. user double-clicks the grouped Save-As control before the host responds), onSaveAs() is called twice, two concurrent modals open, and if both are confirmed, two Action_SaveAs messages are posted, triggering two PutRelativeFile calls. A simple boolean flag or a check for an already-open modal (useModals exposes the modal stack) would prevent this.

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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`The app provider extension > should be able to load an iFrame via get 1`] = `
"<iframe src="https://example.test/d12ab86/loe009157-MzBw" class="oc-width-1-1 oc-height-1-1" title="&quot;example-app&quot; app content area" allowfullscreen="" allow="camera; clipboard-read; clipboard-write"></iframe>
"<iframe src="https://example.test/d12ab86/loe009157-MzBw?ui_defaults=SaveAsMode%3Dgroup" class="oc-width-1-1 oc-height-1-1" title="&quot;example-app&quot; app content area" allowfullscreen="" allow="camera; clipboard-read; clipboard-write"></iframe>

<!--v-if-->"
`;
Expand All @@ -10,10 +10,10 @@ exports[`The app provider extension > should be able to load an iFrame via post
"<!--v-if-->

<div class="oc-height-1-1 oc-width-1-1">
<form action="https://example.test/d12ab86/loe009157-MzBw" target="app-iframe" method="post"><input type="submit" class="oc-hidden" value="[object Object]">
<form action="https://example.test/d12ab86/loe009157-MzBw?ui_defaults=SaveAsMode%3Dgroup" target="app-iframe" method="post"><input type="submit" class="oc-hidden" value="[object Object]">
<div><input name="access_token" type="hidden" value="asdfsadfsadf"></div>
<div><input name="access_token_ttl" type="hidden" value="123456"></div>
</form> <iframe name="app-iframe" src="https://example.test/d12ab86/loe009157-MzBw" class="oc-width-1-1 oc-height-1-1" title="&quot;example-app&quot; app content area" allowfullscreen="" allow="camera; clipboard-read; clipboard-write"></iframe>
</form> <iframe name="app-iframe" src="https://example.test/d12ab86/loe009157-MzBw?ui_defaults=SaveAsMode%3Dgroup" class="oc-width-1-1 oc-height-1-1" title="&quot;example-app&quot; app content area" allowfullscreen="" allow="camera; clipboard-read; clipboard-write"></iframe>
</div>"
`;

Expand Down
Loading