Skip to content
This repository was archived by the owner on Jul 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all 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
207 changes: 194 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 @@
useCapabilityStore,
useConfigStore,
useMessages,
useModals,
useRequest,
useAppProviderService,
useRoute,
Expand Down Expand Up @@ -67,6 +83,7 @@
const language = useGettext()
const { $gettext } = language
const { showErrorMessage } = useMessages()
const { dispatchModal } = useModals()
const capabilityStore = useCapabilityStore()
const configStore = useConfigStore()
const route = useRoute()
Expand All @@ -93,10 +110,150 @@
return appProviderService.appNames.find((appName) => appName.toLowerCase() === lowerCaseAppName)
})

// The grouped Save-As control and the Host_PostmessageReady handshake are
// Collabora-specific WOPI extensions; other app providers don't implement them.
const isCollabora = computed(() => unref(appName) === 'Collabora')

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. Resolved against the current origin so a
// relative `app_url` (WOPI server co-hosted with the web app) still yields a
// usable origin instead of permanently failing the postMessage origin check.
const appOrigin = computed(() => {
Comment thread
LukasHirt marked this conversation as resolved.
Resolved
try {
return new URL(unref(appUrl), window.location.origin).origin

Check warning on line 129 in packages/web-app-external/src/App.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=owncloud_web&issues=AZ8d0F1PWmpKfsku_Wkq&open=AZ8d0F1PWmpKfsku_Wkq&pullRequest=13906
} 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`,
// which is a semicolon-delimited list, so append rather than overwrite it in
// case the server already decorates the URL with its own defaults.
const withSaveAsUiDefaults = (rawUrl: string) => {
try {
const url = new URL(rawUrl)
const existing = url.searchParams.get('ui_defaults')
url.searchParams.set(
'ui_defaults',
existing ? `${existing};SaveAsMode=group` : 'SaveAsMode=group'
)
return url.href
} catch {
return rawUrl
}
}

// Post a WOPI postMessage into the editor iframe (messages are JSON strings).
// Accepts an explicit target/origin so callers that captured them at the time
// a user action started (e.g. opening the Save-As modal) don't re-read the
// live refs, which may have moved on to a different resource by the time the
// user confirms. Returns whether the message was actually sent.
const postToApp = (
message: Record<string, unknown>,
target: Window | null | undefined = unref(appIframe)?.contentWindow,
origin: string = unref(appOrigin)
): boolean => {
if (!target || !origin) {
return false
}
target.postMessage(JSON.stringify({ ...message, SendTime: Date.now() }), origin)
return true
}

// 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. This handshake is Collabora-specific; other providers don't expect it.
const onIframeLoad = () => {
Comment thread
LukasHirt marked this conversation as resolved.
Resolved
if (!unref(isCollabora)) {
return
}
postToApp({ MessageId: 'Host_PostmessageReady', Values: {} })
}

// Reject empty names, path separators and the reserved "." / ".." names before
// the name is sent on. The server-side `PutRelativeFile` sanitizes the name as
// well, but validating here gives immediate feedback and avoids a pointless
// round-trip on invalid input.
const validateSaveAsFilename = (filename: string): string => {
Comment thread
LukasHirt marked this conversation as resolved.
Resolved
const trimmed = filename?.trim()
if (!trimmed) {
return $gettext('The file name cannot be empty.')
}
if (/[/\\]/.test(trimmed)) {
return $gettext('The file name cannot contain "/" or "\\".')

Check warning on line 193 in packages/web-app-external/src/App.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=owncloud_web&issues=AZ8HyZoqBqz8IPvddjsg&open=AZ8HyZoqBqz8IPvddjsg&pullRequest=13906
}
if (trimmed === '.' || trimmed === '..') {
return $gettext('The file name cannot be "." or "..".')
}
return ''
}

// Guards against a second Save-As modal being dispatched while one is already
// open (e.g. the editor's grouped Save-As control emitting `UI_SaveAs` twice
// in quick succession).
const isSaveAsModalOpen = ref(false)

// 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 = () => {
Comment thread
LukasHirt marked this conversation as resolved.
Resolved
if (props.isReadOnly) {
showErrorMessage({ title: $gettext('Cannot save a copy: file is read-only') })
return
}
if (unref(isSaveAsModalOpen)) {
return
}

// Capture the target iframe/origin now, at dispatch time, rather than
// re-reading the (reactive) refs in `onConfirm`: if the component gets
// reused for a different resource while the modal is open, the live refs
// would point at the new document by the time the user presses Save.
const target = unref(appIframe)?.contentWindow
const origin = unref(appOrigin)

isSaveAsModalOpen.value = true
dispatchModal({
variation: 'passive',
title: $gettext('Save a copy'),
confirmText: $gettext('Save'),
hasInput: true,
inputValue: props.resource.name,
inputLabel: $gettext('File name'),
onInput: (filename: string, setError: (error: string) => void) => {
setError(validateSaveAsFilename(filename))
},
onCancel: () => {
isSaveAsModalOpen.value = false
},
onConfirm: (filename: string) => {
Comment thread
LukasHirt marked this conversation as resolved.
Resolved
isSaveAsModalOpen.value = false
// Guard again on confirm (defense-in-depth); onInput normally prevents
// reaching here with an invalid name.
if (validateSaveAsFilename(filename)) {
return
}
const sent = postToApp(
{ MessageId: 'Action_SaveAs', Values: { Filename: filename.trim(), Notify: true } },
target,
origin
)
if (!sent) {
showErrorMessage({ title: $gettext('Cannot save a copy: the editor is not available') })
}
}
})
}

const iFrameTitle = computed(() => {
return $gettext('"%{appName}" app content area', {
Expand Down Expand Up @@ -157,7 +314,9 @@
throw new Error('Error in app server response')
}

appUrl.value = response.data.app_url
appUrl.value = unref(isCollabora)
? withSaveAsUiDefaults(response.data.app_url)
: response.data.app_url
method.value = response.data.method

if (response.data.form_parameters) {
Expand All @@ -179,20 +338,42 @@
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) => {
// Fail closed: reject every message until the editor origin is known (i.e.
// `appUrl` has resolved to an absolute URL) and accept only messages from that
// exact origin. The listener is attached on mount, before the WOPI `open_url`
// POST resolves, so an empty `appOrigin` must reject rather than wave messages
// through. No legitimate editor message can arrive before `appUrl` is set (the
// iframe cannot have loaded yet), so this is strictly correct and also covers a
// backend returning a non-absolute `app_url`.
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':
Comment thread
LukasHirt marked this conversation as resolved.
Resolved
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
Loading
Loading