diff --git a/appinfo/routes.php b/appinfo/routes.php index 7495440142d..1d75049aa91 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -69,5 +69,7 @@ ['name' => 'Workspace#publicFolder', 'url' => '/public/workspace', 'verb' => 'GET'], /** @see Controller\WorkspaceController::direct() */ ['name' => 'Workspace#direct', 'url' => '/workspace/direct', 'verb' => 'POST'], + /** @see Controller\DirectEditingController::edit() */ + ['name' => 'DirectEditing#edit', 'url' => '/directEditing/edit', 'verb' => 'POST'], ] ]; diff --git a/composer/composer/autoload_classmap.php b/composer/composer/autoload_classmap.php index 38c8396e024..f0b93417fe0 100644 --- a/composer/composer/autoload_classmap.php +++ b/composer/composer/autoload_classmap.php @@ -21,6 +21,7 @@ 'OCA\\Text\\Context\\UnauthorizedFileContext' => $baseDir . '/../lib/Context/UnauthorizedFileContext.php', 'OCA\\Text\\Controller\\AiController' => $baseDir . '/../lib/Controller/AiController.php', 'OCA\\Text\\Controller\\AttachmentController' => $baseDir . '/../lib/Controller/AttachmentController.php', + 'OCA\\Text\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php', 'OCA\\Text\\Controller\\ISessionAwareController' => $baseDir . '/../lib/Controller/ISessionAwareController.php', 'OCA\\Text\\Controller\\NavigationController' => $baseDir . '/../lib/Controller/NavigationController.php', 'OCA\\Text\\Controller\\PublicSessionController' => $baseDir . '/../lib/Controller/PublicSessionController.php', diff --git a/composer/composer/autoload_static.php b/composer/composer/autoload_static.php index 64514d97563..0d558f751fa 100644 --- a/composer/composer/autoload_static.php +++ b/composer/composer/autoload_static.php @@ -36,6 +36,7 @@ class ComposerStaticInitText 'OCA\\Text\\Context\\UnauthorizedFileContext' => __DIR__ . '/..' . '/../lib/Context/UnauthorizedFileContext.php', 'OCA\\Text\\Controller\\AiController' => __DIR__ . '/..' . '/../lib/Controller/AiController.php', 'OCA\\Text\\Controller\\AttachmentController' => __DIR__ . '/..' . '/../lib/Controller/AttachmentController.php', + 'OCA\\Text\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php', 'OCA\\Text\\Controller\\ISessionAwareController' => __DIR__ . '/..' . '/../lib/Controller/ISessionAwareController.php', 'OCA\\Text\\Controller\\NavigationController' => __DIR__ . '/..' . '/../lib/Controller/NavigationController.php', 'OCA\\Text\\Controller\\PublicSessionController' => __DIR__ . '/..' . '/../lib/Controller/PublicSessionController.php', diff --git a/lib/Controller/DirectEditingController.php b/lib/Controller/DirectEditingController.php new file mode 100644 index 00000000000..a7069c61164 --- /dev/null +++ b/lib/Controller/DirectEditingController.php @@ -0,0 +1,81 @@ +directEditingManager->getToken($token); + try { + $token->useTokenScope(); + $user = $this->userManager->get($token->getUser()); + if ($user === null) { + throw new NotFoundException(); + } + $file = $token->getFile(); + $context = $this->fileContextFactory->build($user, 'file', $file->getId(), $file); + $session = $this->apiService->create($context, null); + $this->logger->debug('Started direct editing session.', ['token' => $token->getToken()]); + return new DataResponse([ + 'fileId' => $token->getFile()->getId(), + 'mimetype' => $token->getFile()->getMimeType(), + 'session' => $session->getData(), + ]); + } catch (InvalidPathException|NotFoundException|NotPermittedException) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } finally { + $this->revertTokenScope(); + } + } + + private function revertTokenScope(): void { + $this->userSession->setUser(null); + } + +} diff --git a/lib/Middleware/SessionMiddleware.php b/lib/Middleware/SessionMiddleware.php index 750bfcaf575..98234f92c8e 100644 --- a/lib/Middleware/SessionMiddleware.php +++ b/lib/Middleware/SessionMiddleware.php @@ -30,6 +30,7 @@ use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use ReflectionException; class SessionMiddleware extends Middleware { @@ -43,6 +44,7 @@ public function __construct( private readonly IL10N $l10n, private readonly IUserManager $userManager, private readonly FileService $fileService, + private readonly LoggerInterface $logger, ) { } @@ -155,6 +157,7 @@ private function getDocument(string $documentId): ?Document { } public function afterException($controller, $methodName, \Exception $exception): JSONResponse|Response { + $this->logger->debug('After exception', ['exception' => $exception]); if ($exception instanceof InvalidDocumentBaseVersionEtagException) { return new JSONResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED); } diff --git a/playwright/e2e/direct-editing.spec.ts b/playwright/e2e/direct-editing.spec.ts new file mode 100644 index 00000000000..543387bed40 --- /dev/null +++ b/playwright/e2e/direct-editing.spec.ts @@ -0,0 +1,34 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as directEditFileTest } from '../support/fixtures/direct-edit-file.ts' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { test as randomUserTest } from '../support/fixtures/random-user.ts' + +const test = mergeTests(editorTest, randomUserTest, directEditFileTest) + +test.use({ + fileName: 'initial.md', + fileContent: '# Initial File', +}) + +test.describe('Direct editing', () => { + test('opens once', async ({ editor, open }) => { + await open() + await expect(editor.getHeading({ name: 'Initial File' })).toBeVisible() + }) + + test('opens next file', async ({ editor, open, owner, page }) => { + const secondFile = await owner.uploadFile({ name: 'second.md', content: '# Second file' }) + const { url } = await secondFile.createDirectEditingLink() + await open() + await expect(editor.getHeading({ name: 'Initial File' })).toBeVisible() + await page.evaluate((url) => { + window.OCA.Text.openDirectEditing(url) + }, url) + await expect(editor.getHeading({ name: 'Second File' })).toBeVisible() + }) +}) diff --git a/playwright/support/fixtures/Node.ts b/playwright/support/fixtures/Node.ts index f53ea4737ec..9beab09215c 100644 --- a/playwright/support/fixtures/Node.ts +++ b/playwright/support/fixtures/Node.ts @@ -74,6 +74,23 @@ export class Node { return { token, id } } + async createDirectEditingLink(): Promise<{ url: string }> { + const path = `/${this.name}` + const response = await this.page.request.post( + '/ocs/v2.php/apps/files/api/v1/directEditing/open?format=json', + { + data: { path }, + headers: ocsHeaders, + /* + 'OCS-ApiRequest': 'true', + 'Content-Type': 'application/x-www-form-urlencoded', + */ + }, + ) + const { ocs } = await response.json() as { ocs: { data: { url: string } } } + return ocs.data + } + async getContent() { const response = await this.page.request.get(`/remote.php/webdav/${this.name}`) return response.text() diff --git a/playwright/support/fixtures/direct-edit-file.ts b/playwright/support/fixtures/direct-edit-file.ts new file mode 100644 index 00000000000..1000d6c422c --- /dev/null +++ b/playwright/support/fixtures/direct-edit-file.ts @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { test as base } from './shared-file.ts' + +export interface DirectEditFileFixture { + directEditing: { url: string } +} + +/** + * This test fixture uploads the file to a user's root directory and shares it. + */ +export const test = base.extend({ + + directEditing: async ({ file }, use) => { + const { url } = await file.createDirectEditingLink() + await use({ url }) + }, + + open: async ({ page, directEditing }, use) => { + const open = async () => { + await page.goto(directEditing.url) + await expect(page.getByRole('toolbar', { name: 'Formatting menu bar' })) + .toBeVisible() + } + await use(open) + }, + +}) diff --git a/src/apis/connect.ts b/src/apis/connect.ts index d7d6dd984c7..39857fe3be6 100644 --- a/src/apis/connect.ts +++ b/src/apis/connect.ts @@ -5,10 +5,10 @@ import type { Connection } from '../types/Connection.ts' import type { Document } from '../types/Document.ts' -import type { GuestSession, Session } from '../types/Session.ts' +import type { GuestSession, Session, UserSession } from '../types/Session.ts' import axios from '@nextcloud/axios' -import { generateUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' export interface OpenContextParams { type: string @@ -75,6 +75,28 @@ export async function openShare(params: OpenShareParams): Promise<{ connection: return { connection, data: response.data } } +interface DirectEditingState { + session: UserSession + fileId: number + mimeType: string +} + +/** + * Open a direct editing url to start editing the file in question + * + * Opens the direct editing token without loading a new html page. + * The response can be used to load the file in the current page. + * + * @param params send with the request. + * @param params.url the direct editing url as provided by the server. + */ +export async function openDirectEditing(params: { url: string }): Promise { + const url = generateOcsUrl('/apps/text/directEditing/edit') + const token = params.url.split('/').at(-1) + const response = await axios.post(url, { ...params, token }) + return response.data.ocs.data +} + /** * Update the guest name * diff --git a/src/components/EditorReloader.vue b/src/components/EditorReloader.vue index 798e9d1981a..e485ade5ff7 100644 --- a/src/components/EditorReloader.vue +++ b/src/components/EditorReloader.vue @@ -26,6 +26,8 @@ import type { ComponentInstance, ShallowRef } from 'vue' import { nextTick, ref, useTemplateRef, watch } from 'vue' import CollaborativeEditor from './CollaborativeEditor.vue' +import { logger } from '../helpers/logger.ts' + const props = defineProps(CollaborativeEditor.props) defineEmits(['focus', 'push:forbidden']) const reloading = ref(false) @@ -37,6 +39,14 @@ watch(reloading, (val) => { } }) +watch( + () => props.initialSession, + (cur, old) => { + logger.debug('initialSession changed', { old, cur }) + reloading.value = true + }, +) + const editorComponent = useTemplateRef('editor-component') as Readonly>> defineExpose({ get editor() { diff --git a/src/views/DirectEditing.vue b/src/views/DirectEditing.vue index 3f1f30618a5..f5cbfe76bab 100644 --- a/src/views/DirectEditing.vue +++ b/src/views/DirectEditing.vue @@ -11,7 +11,7 @@ :context active autofocus - :mime="initial.mimetype" + :mime="mimetype" isDirectEditing @ready="loaded" @push:forbidden="onPushForbidden"> @@ -24,8 +24,10 @@