From e927907c48f0aa879a09a3cd6b431c64806b3220 Mon Sep 17 00:00:00 2001 From: Raul Date: Thu, 8 Sep 2022 14:12:20 +0200 Subject: [PATCH] Add local editing button to Nextcloud Office Squashed commits: - Fix CSP tests - Update logic on opening file locally to prevent race condition with collabora post message API - Unlock file before opening locally - Add confirmation dialog - Toolbar button: open file locally Signed-off-by: raul Signed-off-by: Raul --- img/launch.svg | 4 ++ lib/Listener/CSPListener.php | 1 + src/document.js | 79 +++++++++++++++++++++++++- src/helpers/url.js | 5 ++ tests/lib/Listener/CSPListenerTest.php | 14 ++--- 5 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 img/launch.svg diff --git a/img/launch.svg b/img/launch.svg new file mode 100644 index 000000000..e39036d57 --- /dev/null +++ b/img/launch.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lib/Listener/CSPListener.php b/lib/Listener/CSPListener.php index 38308173c..81d95171c 100644 --- a/lib/Listener/CSPListener.php +++ b/lib/Listener/CSPListener.php @@ -68,6 +68,7 @@ class CSPListener implements IEventListener { $policy = new EmptyContentSecurityPolicy(); $policy->addAllowedFrameDomain("'self'"); + $policy->addAllowedFrameDomain("nc:"); foreach ($urls as $url) { $policy->addAllowedFrameDomain($url); diff --git a/src/document.js b/src/document.js index 3bf3b8842..5975fc786 100644 --- a/src/document.js +++ b/src/document.js @@ -1,5 +1,5 @@ import { emit } from '@nextcloud/event-bus' -import { getRootUrl } from '@nextcloud/router' +import { getRootUrl, imagePath } from '@nextcloud/router' import { getRequestToken } from '@nextcloud/auth' import Config from './services/config.tsx' import { setGuestName, shouldAskForGuestName } from './helpers/guestName' @@ -11,9 +11,10 @@ import { isDirectEditing, isMobileInterfaceAvailable, } from './helpers/mobile' -import { getWopiUrl, getSearchParam } from './helpers/url' +import { getWopiUrl, getSearchParam, getNextcloudUrl } from './helpers/url' import '../css/document.scss' +import axios from '@nextcloud/axios' const PostMessages = new PostMessageService({ parent: window.parent, @@ -307,6 +308,15 @@ const documentsMain = { if (Config.get('userId') === null) { PostMessages.sendWOPIPostMessage('loolframe', 'Hide_Menu_Item', { id: 'insertgraphicremote' }) + } else { + PostMessages.sendWOPIPostMessage('loolframe', 'Insert_Button', { + id: 'Open_Local_Editor', + imgurl: window.location.protocol + '//' + getNextcloudUrl() + imagePath('richdocuments', 'launch.svg'), + mobile: false, + label: t('richdocuments', 'Open in local editor'), + hint: t('richdocuments', 'Open in local editor'), + insertBefore: 'undo', + }) } emit('richdocuments:wopi-load:succeeded', { @@ -442,6 +452,23 @@ const documentsMain = { case 'File_Rename': documentsMain.fileName = args.NewName break + case 'Clicked_Button': + if (parsed.args.Id === 'Open_Local_Editor') { + documentsMain.UI.initiateOpenLocally() + } else { + console.debug('[document] Unhandled `Clicked_Button` post message', parsed) + } + break + case 'Get_Views_Resp': + if (documentsMain.openingLocally) { + documentsMain.UI.removeViews(parsed.args) + documentsMain.unlockFile() + .catch(_ => {}) // Unlocking failed, possibly because file was not locked, we want to proceed regardless. + .then(() => { + documentsMain.openLocally() + }) + } + break default: console.debug('[document] Unhandled post message', parsed) } @@ -500,6 +527,53 @@ const documentsMain = { $(document.body).removeClass('claro') }) }, + + initiateOpenLocally() { + OC.dialogs.confirmDestructive( + t('richdocuments', 'When opening a file locally, the document will close for all users currently viewing the document.'), + t('richdocuments', 'Open file locally'), + { + type: OC.dialogs.YES_NO_BUTTONS, + confirm: t('richdocuments', 'Open locally'), + confirmClasses: 'error', + cancel: t('richdocuments', 'Continue editing online'), + }, + (decision) => { + if (!decision) { + return + } + documentsMain.openingLocally = true + PostMessages.sendWOPIPostMessage('loolframe', 'Get_Views') + } + ) + }, + + removeViews(views) { + PostMessages.sendWOPIPostMessage('loolframe', 'Action_Save', { + DontTerminateEdit: false, + DontSaveIfUnmodified: false, + Notify: false, + }) + + views.forEach((view) => { + PostMessages.sendWOPIPostMessage('loolframe', 'Action_RemoveView', { ViewId: Number(view.ViewId) }) + }) + }, + }, + + unlockFile() { + const unlockUrl = getRootUrl() + '/index.php/apps/richdocuments/wopi/files/' + documentsMain.fileId + const unlockConfig = { + headers: { 'X-WOPI-Override': 'UNLOCK' } + } + return axios.post(unlockUrl, { access_token: documentsMain.token }, unlockConfig) + }, + + openLocally() { + if (documentsMain.openingLocally) { + documentsMain.openingLocally = false + window.location.href = 'nc://open/' + Config.get('userId') + '@' + getNextcloudUrl() + OC.encodePath(documentsMain.fullPath) + } }, onStartup() { @@ -560,6 +634,7 @@ const documentsMain = { $('footer,nav').show() documentsMain.UI.hideEditor() + documentsMain.openLocally() PostMessages.sendPostMessage('parent', 'close', '*') }, diff --git a/src/helpers/url.js b/src/helpers/url.js index 3054f42e7..d0a0abd05 100644 --- a/src/helpers/url.js +++ b/src/helpers/url.js @@ -86,6 +86,10 @@ const getDocumentUrlForFile = (fileDir, fileId) => { }) } +const getNextcloudUrl = () => { + return window.location.host + (window.location.port ? `:${window.location.port}` : '') +} + export { getSearchParam, getWopiUrl, @@ -93,4 +97,5 @@ export { getDocumentUrlFromTemplate, getDocumentUrlForPublicFile, getDocumentUrlForFile, + getNextcloudUrl, } diff --git a/tests/lib/Listener/CSPListenerTest.php b/tests/lib/Listener/CSPListenerTest.php index e6b69f42f..57e4d0417 100644 --- a/tests/lib/Listener/CSPListenerTest.php +++ b/tests/lib/Listener/CSPListenerTest.php @@ -96,7 +96,7 @@ class CSPListenerTest extends TestCase { $policy = $this->getMergedPolicy(); - self::assertEquals(["'self'", "http://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "nc:" , "http://public"], $policy->getAllowedFrameDomains()); self::assertEquals(["'self'", "http://public"], $policy->getAllowedFormActionDomains()); } @@ -123,7 +123,7 @@ class CSPListenerTest extends TestCase { $policy = $this->getMergedPolicy(); - self::assertEquals(["'self'"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "nc:"], $policy->getAllowedFrameDomains()); self::assertEquals(["'self'"], $policy->getAllowedFormActionDomains()); } @@ -135,7 +135,7 @@ class CSPListenerTest extends TestCase { $policy = $this->getMergedPolicy(); - self::assertEquals(["'self'", "http://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "nc:", "http://public"], $policy->getAllowedFrameDomains()); self::assertEquals(["'self'", "http://public"], $policy->getAllowedFormActionDomains()); } @@ -147,7 +147,7 @@ class CSPListenerTest extends TestCase { $policy = $this->getMergedPolicy(); - self::assertEquals(["'self'", "https://public"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "nc:", "https://public"], $policy->getAllowedFrameDomains()); self::assertEquals(["'self'", "https://public"], $policy->getAllowedFormActionDomains()); } @@ -165,7 +165,7 @@ class CSPListenerTest extends TestCase { $policy = $this->getMergedPolicy(); - self::assertEquals(["'self'", "https://public", "*.example.com"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "nc:", "https://public", "*.example.com"], $policy->getAllowedFrameDomains()); self::assertEquals(["'self'", "https://public", "*.example.com"], $policy->getAllowedFormActionDomains()); } @@ -180,7 +180,7 @@ class CSPListenerTest extends TestCase { $policy = $this->getMergedPolicy(); - self::assertEquals(["'self'", "http://internal"], $policy->getAllowedFrameDomains()); + self::assertEquals(["'self'", "nc:", "http://internal"], $policy->getAllowedFrameDomains()); self::assertEquals(["'self'", "http://internal"], $policy->getAllowedFormActionDomains()); } @@ -205,7 +205,7 @@ class CSPListenerTest extends TestCase { $policy = $manager->getDefaultPolicy(); - self::assertArrayUnordered(["'self'", "external.example.com", "http://public"], $policy->getAllowedFrameDomains(), "Domains are equal", 0.0, 10, true); + self::assertArrayUnordered(["'self'", "external.example.com", "http://public", "nc:"], $policy->getAllowedFrameDomains(), "Domains are equal", 0.0, 10, true); self::assertArrayUnordered(["'self'", "external.example.com", "http://public"], $policy->getAllowedFormActionDomains()); }