diff --git a/.github/workflows/cypress-e2e.yml b/.github/workflows/cypress-e2e.yml index 1575e0965c..0d219258dc 100644 --- a/.github/workflows/cypress-e2e.yml +++ b/.github/workflows/cypress-e2e.yml @@ -171,6 +171,7 @@ jobs: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} SPLIT: 3 SPLIT_INDEX: ${{ env.container_index }} + CODE_RELEASE: ${{ matrix.code-image }} - name: Upload test failure screenshots uses: actions/upload-artifact@v2 diff --git a/appinfo/routes.php b/appinfo/routes.php index 7fab422a5a..89571c6d23 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -11,6 +11,7 @@ // Page rendering of documents ['name' => 'document#index', 'url' => 'index', 'verb' => 'GET'], ['name' => 'document#remote', 'url' => 'remote', 'verb' => 'GET'], + ['name' => 'document#remotePost', 'url' => 'remote', 'verb' => 'POST'], ['name' => 'document#createFromTemplate', 'url' => 'indexTemplate', 'verb' => 'GET'], ['name' => 'document#publicPage', 'url' => '/public', 'verb' => 'GET'], ['name' => 'document#token', 'url' => '/token', 'verb' => 'POST'], diff --git a/cypress/e2e/direct.spec.js b/cypress/e2e/direct.spec.js index 7247454102..c93b070a8c 100644 --- a/cypress/e2e/direct.spec.js +++ b/cypress/e2e/direct.spec.js @@ -11,7 +11,7 @@ const createDirectEditingLink = (user, fileId) => { body: { fileId, }, - auth: { user: user.userId, pass: user.password }, + // auth: { user: user.userId, pass: user.password }, headers: { 'OCS-ApiRequest': 'true', 'Content-Type': 'application/x-www-form-urlencoded', @@ -88,4 +88,19 @@ describe('Direct editing (legacy)', function() { }) }) + it('Open a remotely shared file', () => { + cy.createRandomUser().then(shareRecipient => { + cy.login(randUser) + cy.shareFileToRemoteUser(randUser, '/document.odt', shareRecipient) + .then(incomingFileId => { + createDirectEditingLink(shareRecipient, incomingFileId) + .then((token) => { + cy.logout() + cy.visit(token) + cy.waitForCollabora(false) + }) + }) + }) + }) + }) diff --git a/cypress/e2e/integration.spec.js b/cypress/e2e/integration.spec.js index 25fadc40a7..e031596592 100644 --- a/cypress/e2e/integration.spec.js +++ b/cypress/e2e/integration.spec.js @@ -2,6 +2,7 @@ * SPDX-FileCopyrightText: 2023 Julius Härtl * SPDX-License-Identifier: AGPL-3.0-or-later */ + describe('Nextcloud integration', function() { let randUser @@ -61,19 +62,18 @@ describe('Nextcloud integration', function() { cy.get('#tab-version_vue .version__info__label').contains('Current version') }) - // Currently it seems that Collabora is missing the save as button it('Save as', function() { + const exportFilename = 'document.rtf' cy.get('@loleafletframe').within(() => { cy.get('#File-tab-label').click() - cy.get('#file-saveas').click() - // FIXME: Seems currently broken, so let's skip this step - // cy.get('#saveas-entries #saveas-entry-1').click() + cy.get('#saveas').click() + cy.get('#saveas-entries #saveas-entry-1').click() }) cy.get('.saveas-dialog').should('be.visible') cy.get('.saveas-dialog input[type=text]') .should('be.visible') - .should('have.value', '/document.odt') + .should('have.value', `/${exportFilename}`) cy.get('.saveas-dialog button.button-vue--vue-primary').click() @@ -85,7 +85,7 @@ describe('Nextcloud integration', function() { // FIXME: We should not need to reload cy.get('.breadcrumb__crumbs a').eq(0).click({ force: true }) - cy.openFile('document.odt') + cy.openFile(exportFilename) }) it('Open locally', function() { diff --git a/cypress/e2e/share-federated.spec.js b/cypress/e2e/share-federated.spec.js new file mode 100644 index 0000000000..7d00b44644 --- /dev/null +++ b/cypress/e2e/share-federated.spec.js @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2023 Julius Härtl + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { User } from '@nextcloud/cypress' +import { randHash } from '../utils/index.js' +const shareOwner = new User(randHash(), randHash()) +const shareRecipient = new User(randHash(), randHash()) + +describe('Federated sharing of office documents', function() { + + before(function() { + cy.nextcloudEnableApp('testing') + cy.nextcloudTestingAppConfigSet('richdocuments', 'uiDefaults-UIMode', 'notebookbar') + cy.createUser(shareRecipient) + cy.createUser(shareOwner) + + cy.uploadFile(shareOwner, 'document.odt', 'application/vnd.oasis.opendocument.text', '/document.odt') + }) + + it('Open a remotely shared file', function() { + const filename = 'document.odt' + + cy.login(shareOwner) + cy.shareFileToRemoteUser(shareOwner, '/document.odt', shareRecipient) + cy.login(shareRecipient) + + cy.visit('/apps/files', { + onBeforeLoad(win) { + cy.spy(win, 'postMessage').as('postMessage') + }, + }) + cy.openFile(filename) + cy.waitForViewer() + cy.waitForCollabora(true, true).within(() => { + cy.get('#closebutton').click() + cy.get('#viewer', { timeout: 5000 }).should('not.exist') + }) + }) +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index d3a0d219e3..2868e3cb36 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -109,6 +109,44 @@ Cypress.Commands.add('shareFileToUser', (user, path, targetUser, shareData = {}) }) }) +Cypress.Commands.add('shareFileToRemoteUser', (user, path, targetUser, shareData = {}) => { + cy.login(user) + const federatedId = `${targetUser.userId}@${url}` + return cy.ocsRequest(user, { + method: 'POST', + url: `${url}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, + body: { + path, + shareType: 6, + shareWith: federatedId, + ...shareData, + }, + }).then(response => { + cy.log(`${user.userId} shared ${path} with ${federatedId}`, response.status) + cy.login(targetUser) + return cy.ocsRequest(targetUser, { + method: 'GET', + url: `${url}/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending?format=json`, + }) + }).then(({ body }) => { + for (const index in body.ocs.data) { + cy.ocsRequest(targetUser, { + method: 'POST', + url: `${url}/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/${body.ocs.data[index].id}`, + }) + return cy.wrap(body.ocs.data[index].id) + } + }).then((shareId) => { + cy.ocsRequest(targetUser, { + method: 'GET', + url: `${url}/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/${shareId}?format=json`, + }).then((response) => { + cy.login(user) + return cy.wrap(response.body.ocs.data['file_id']) + }) + }) +}) + Cypress.Commands.add('shareLink', (user, path, shareData = {}) => { cy.login(user) cy.ocsRequest(user, { @@ -190,9 +228,10 @@ Cypress.Commands.add('waitForViewer', () => { .and('not.have.class', 'icon-loading') }) -Cypress.Commands.add('waitForCollabora', (wrapped = false) => { +Cypress.Commands.add('waitForCollabora', (wrapped = false, federated = false) => { + const wrappedFrameIdentifier = federated ? 'coolframe' : 'documentframe' if (wrapped) { - cy.get('[data-cy="documentframe"]', { timeout: 30000 }) + cy.get(`[data-cy="${wrappedFrameIdentifier}"]`, { timeout: 30000 }) .its('0.contentDocument') .its('body').should('not.be.empty') .should('be.visible').should('not.be.empty') @@ -207,7 +246,13 @@ Cypress.Commands.add('waitForCollabora', (wrapped = false) => { .its('0.contentDocument') .its('body').should('not.be.empty') .as('loleafletframe') - cy.get('@loleafletframe').find('#main-document-content').should('be.visible') + + cy.get('@loleafletframe') + .within(() => { + cy.get('#main-document-content').should('be.visible') + }) + + return cy.get('@loleafletframe') }) Cypress.Commands.add('waitForPostMessage', (messageId, values = undefined) => { diff --git a/lib/Controller/DocumentController.php b/lib/Controller/DocumentController.php index acd834287c..e5759751f7 100644 --- a/lib/Controller/DocumentController.php +++ b/lib/Controller/DocumentController.php @@ -282,7 +282,9 @@ public function remote(string $shareToken, string $remoteServer, string $remoteS 'userId' => $remoteWopi->getEditorUid() ? ($remoteWopi->getEditorUid() . '@' . $remoteServer) : null, ]; - return $this->documentTemplateResponse($wopi, $params); + $response = $this->documentTemplateResponse($wopi, $params); + $response->addHeader('X-Frame-Options', 'ALLOW'); + return $response; } } catch (ShareNotFound $e) { return new TemplateResponse('core', '404', [], 'guest'); @@ -294,6 +296,16 @@ public function remote(string $shareToken, string $remoteServer, string $remoteS return new TemplateResponse('core', '403', [], 'guest'); } + /** + * Open file on Source instance with token from Initiator instance + */ + #[PublicPage] + #[NoCSRFRequired] + public function remotePost(string $shareToken, string $remoteServer, string $remoteServerToken, ?string $filePath = null): TemplateResponse { + return $this->remote($shareToken, $remoteServer, $remoteServerToken, $filePath); + } + + private function renderErrorPage(string $message, int $status = Http::STATUS_INTERNAL_SERVER_ERROR): TemplateResponse { $params = [ 'errors' => [['error' => $message]] @@ -375,6 +387,13 @@ public function token(int $fileId, ?string $shareToken = null, ?string $path = n $share = $shareToken ? $this->shareManager->getShareByToken($shareToken) : null; $file = $shareToken ? $this->getFileForShare($share, $fileId, $path) : $this->getFileForUser($fileId, $path); + $federatedUrl = $this->federationService->getRemoteRedirectURL($file); + if ($federatedUrl) { + return new DataResponse([ + 'federatedUrl' => $federatedUrl, + ]); + } + $wopi = $this->getToken($file, $share); $this->tokenManager->setGuestName($wopi, $guestName); diff --git a/lib/Service/FederationService.php b/lib/Service/FederationService.php index de287571bd..99305a1332 100644 --- a/lib/Service/FederationService.php +++ b/lib/Service/FederationService.php @@ -197,6 +197,7 @@ public function getRemoteRedirectURL(File $item, ?Direct $direct = null, ?IShare return null; } + $remote = $item->getStorage()->getRemote(); $remoteCollabora = $this->getRemoteCollaboraURL($remote); if ($remoteCollabora !== '') { @@ -214,6 +215,7 @@ public function getRemoteRedirectURL(File $item, ?Direct $direct = null, ?IShare $this->tokenManager->extendWithInitiatorUserToken($wopi, $direct->getInitiatorHost(), $direct->getInitiatorToken()); } + $url = rtrim($remote, '/') . '/index.php/apps/richdocuments/remote?shareToken=' . $item->getStorage()->getToken() . '&remoteServer=' . $initiatorServer . '&remoteServerToken=' . $initiatorToken; diff --git a/src/helpers/coolParameters.js b/src/helpers/coolParameters.js index 67f237d07c..2508daf221 100644 --- a/src/helpers/coolParameters.js +++ b/src/helpers/coolParameters.js @@ -13,18 +13,12 @@ const getUIDefaults = () => { const saveAsMode = 'group' const uiMode = defaults.UIMode ?? 'notebookbar' - const systemDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches - const dataset = (document.body.dataset.themes ? document.body.dataset : parent?.document.body.dataset) ?? {} - const nextcloudDarkMode = dataset?.themeDark === '' || dataset?.themeDarkHighcontrast === '' - const matchedDarkMode = (!dataset?.themes || dataset?.themes === '' || dataset?.themeDefault === '') ? systemDarkMode : nextcloudDarkMode - const uiTheme = matchedDarkMode ? 'dark' : 'light' - let uiDefaults = 'TextRuler=' + textRuler + ';' uiDefaults += 'TextSidebar=' + sidebar + ';TextStatusbar=' + statusBar + ';' uiDefaults += 'PresentationSidebar=' + sidebar + ';PresentationStatusbar=' + statusBar + ';' uiDefaults += 'SpreadsheetSidebar=' + sidebar + ';SpreadsheetStatusbar=true;' uiDefaults += 'UIMode=' + uiMode + ';' - uiDefaults += 'UITheme=' + uiTheme + ';' + uiDefaults += 'UITheme=' + getUITheme() + ';' uiDefaults += 'SaveAsMode=' + saveAsMode + ';' return uiDefaults } @@ -33,6 +27,19 @@ const getCollaboraTheme = () => { return loadState('richdocuments', 'theme', 'nextcloud') } +const getUITheme = () => { + const systemDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + let dataset = {} + try { + dataset = (document.body.dataset.themes ? document.body.dataset : parent?.document.body.dataset) ?? {} + } catch (e) { + // Ignore errors here in case we run into cross-origin domains + } + const nextcloudDarkMode = dataset?.themeDark === '' || dataset?.themeDarkHighcontrast === '' + const matchedDarkMode = (!dataset?.themes || dataset?.themes === '' || dataset?.themeDefault === '') ? systemDarkMode : nextcloudDarkMode + return matchedDarkMode ? 'dark' : 'light' +} + const createDataThemeDiv = (elementType, theme) => { const element = document.createElement(elementType) element.setAttribute('id', 'cool-var-source-' + theme) diff --git a/src/view/Office.vue b/src/view/Office.vue index 3eef5daeb9..6f7a507968 100644 --- a/src/view/Office.vue +++ b/src/view/Office.vue @@ -306,6 +306,14 @@ export default { const { data } = await axios.post(generateUrl('/apps/richdocuments/token'), { fileId: fileid, shareToken: this.shareToken, version, guestName: getGuestNickname(), }) + + if (data.federatedUrl) { + this.$set(this.formData, 'action', data.federatedUrl) + this.$nextTick(() => this.$refs.form.submit()) + this.loading = LOADING_STATE.DOCUMENT_READY + return + } + Config.update('urlsrc', data.urlSrc) Config.update('wopi_callback_url', loadState('richdocuments', 'wopi_callback_url', ''))