diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index a9199305e2..5d3b44a4f5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,6 +25,7 @@ use OCA\Richdocuments\Listener\LoadViewerListener; use OCA\Richdocuments\Listener\OverwritePublicSharePropertiesListener; use OCA\Richdocuments\Listener\ReferenceListener; +use OCA\Richdocuments\Listener\RegisterDirectEditorListener; use OCA\Richdocuments\Listener\RegisterTemplateFileCreatorListener; use OCA\Richdocuments\Listener\ShareLinkListener; use OCA\Richdocuments\Middleware\WOPIMiddleware; @@ -56,6 +57,7 @@ use OCP\BeforeSabrePubliclyLoadedEvent; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; +use OCP\DirectEditing\RegisterDirectEditorEvent; use OCP\Files\Storage\IStorage; use OCP\Files\Template\BeforeGetTemplatesEvent; use OCP\Files\Template\FileCreatedFromTemplateEvent; @@ -82,6 +84,7 @@ public function register(IRegistrationContext $context): void { $context->registerMiddleWare(WOPIMiddleware::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateFileCreatorListener::class); $context->registerEventListener(FileCreatedFromTemplateEvent::class, FileCreatedFromTemplateListener::class); + $context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class); $context->registerEventListener(AddContentSecurityPolicyEvent::class, AddContentSecurityPolicyListener::class); $context->registerEventListener(AddFeaturePolicyEvent::class, AddFeaturePolicyListener::class); $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 2fdf152c40..578c2eb3f8 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -102,32 +102,18 @@ public function __construct( #[\Override] public function getCapabilities() { // Only expose capabilities for users with enabled office or guests (where it depends on the share owner if they have access) - if (!$this->permissionManager->isEnabledForUser() && $this->userId !== null) { + if (!$this->permissionManager->isEnabledForUser($this->userId) && $this->userId !== null) { return []; } if (!$this->capabilities) { $collaboraCapabilities = $this->capabilitiesService->getCapabilities(); - $defaultMimetypes = self::MIMETYPES; - $optionalMimetypes = self::MIMETYPES_OPTIONAL; - - if (!$this->capabilitiesService->hasOtherOOXMLApps()) { - array_push($defaultMimetypes, ...self::MIMETYPES_MSOFFICE); - } else { - array_push($optionalMimetypes, ...self::MIMETYPES_MSOFFICE); - } - - if (!$this->appManager->isEnabledForUser('files_pdfviewer')) { - $defaultMimetypes[] = 'application/pdf'; - $optionalMimetypes = array_diff($optionalMimetypes, ['application/pdf']); - } - $this->capabilities = [ 'richdocuments' => [ 'version' => $this->appManager->getAppVersion('richdocuments'), - 'mimetypes' => $defaultMimetypes, - 'mimetypesNoDefaultOpen' => array_values($optionalMimetypes), + 'mimetypes' => $this->getDefaultMimetypes(), + 'mimetypesNoDefaultOpen' => $this->getOptionalMimetypes(), 'mimetypesSecureView' => $this->config->useSecureViewAdditionalMimes() ? self::SECURE_VIEW_ADDITIONAL_MIMES : [], 'collabora' => $collaboraCapabilities, 'direct_editing' => ($collaboraCapabilities['hasMobileSupport'] ?? false) && $this->config->getAppValue('mobile_editing', 'yes') === 'yes', @@ -150,4 +136,38 @@ public function getCapabilities() { } return $this->capabilities; } + + /** + * @return list + */ + public function getDefaultMimetypes(): array { + $defaultMimetypes = self::MIMETYPES; + + if (!$this->capabilitiesService->hasOtherOOXMLApps()) { + array_push($defaultMimetypes, ...self::MIMETYPES_MSOFFICE); + } + + if (!$this->appManager->isEnabledForUser('files_pdfviewer')) { + $defaultMimetypes[] = 'application/pdf'; + } + + return $defaultMimetypes; + } + + /** + * @return list + */ + public function getOptionalMimetypes(): array { + $optionalMimetypes = self::MIMETYPES_OPTIONAL; + + if ($this->capabilitiesService->hasOtherOOXMLApps()) { + array_push($optionalMimetypes, ...self::MIMETYPES_MSOFFICE); + } + + if (!$this->appManager->isEnabledForUser('files_pdfviewer')) { + $optionalMimetypes = array_diff($optionalMimetypes, ['application/pdf']); + } + + return $optionalMimetypes; + } } diff --git a/lib/Controller/DirectViewController.php b/lib/Controller/DirectViewController.php index b2b9ad7761..fd6e808d89 100644 --- a/lib/Controller/DirectViewController.php +++ b/lib/Controller/DirectViewController.php @@ -9,16 +9,17 @@ use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Db\Direct; use OCA\Richdocuments\Db\DirectMapper; +use OCA\Richdocuments\Service\DirectEditingViewService; use OCA\Richdocuments\Service\FederationService; use OCA\Richdocuments\Service\InitialStateService; use OCA\Richdocuments\Service\UserScopeService; -use OCA\Richdocuments\TemplateManager; use OCA\Richdocuments\TokenManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; use OCP\Files\File; use OCP\Files\Folder; @@ -44,8 +45,8 @@ public function __construct( private InitialStateService $initialState, private IConfig $config, private AppConfig $appConfig, - private TemplateManager $templateManager, private FederationService $federationService, + private DirectEditingViewService $viewService, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -57,7 +58,7 @@ public function __construct( * @PublicPage * * @param string $token - * @return JSONResponse|RedirectResponse|TemplateResponse + * @return JSONResponse|RedirectResponse|TemplateResponse|Response * @throws NotFoundException */ public function show($token) { @@ -88,50 +89,18 @@ public function show($token) { throw new \Exception(); } - /** Open file from remote collabora */ - $federatedUrl = $this->federationService->getRemoteRedirectURL($item, $direct); - if ($federatedUrl !== null) { - $response = new RedirectResponse($federatedUrl); - return $response; - } - - $wopi = null; - $template = $direct->getTemplateId() ? $this->templateManager->get($direct->getTemplateId()) : null; - - if ($template !== null) { - $wopi = $this->tokenManager->generateWopiTokenForTemplate($template, $item->getId(), $direct->getUid(), false, true); - } - - if ($wopi === null) { - $wopi = $this->tokenManager->generateWopiToken((string)$item->getId(), null, $direct->getUid(), true); + // Mirror the legacy "template-id carried on the direct token" flow: + // hand the association to the shared view service so the next render + // picks up the template via `richdocuments_template`. + if ($direct->getTemplateId()) { + $this->viewService->primeTemplateSource($item->getId(), $direct->getTemplateId(), $direct->getUid()); } - $urlSrc = $this->tokenManager->getUrlSrc($item); + return $this->viewService->render($item, $direct->getUid()); } catch (\Exception $e) { $this->logger->error('Failed to generate token for existing file on direct editing', ['exception' => $e]); return $this->renderErrorPage('Failed to open the requested file.'); } - - $relativePath = $folder->getRelativePath($item->getPath()); - - try { - $params = [ - 'permissions' => $item->getPermissions(), - 'title' => basename($relativePath), - 'fileId' => $wopi->getFileid() . '_' . $this->config->getSystemValue('instanceid'), - 'token' => $wopi->getToken(), - 'token_ttl' => $wopi->getExpiry(), - 'urlsrc' => $urlSrc, - 'path' => $relativePath, - 'direct' => true, - 'userId' => $direct->getUid(), - ]; - - return $this->documentTemplateResponse($wopi, $params); - } catch (\Exception $e) { - $this->logger->error($e->getMessage(), ['exception' => $e]); - return $this->renderErrorPage('Failed to open the requested file.'); - } } public function showPublicShare(Direct $direct) { diff --git a/lib/Controller/OCSController.php b/lib/Controller/OCSController.php index 2dcd76a675..c2e93d581c 100644 --- a/lib/Controller/OCSController.php +++ b/lib/Controller/OCSController.php @@ -8,6 +8,7 @@ use Exception; use GuzzleHttp\Exception\BadResponseException; +use OCA\Richdocuments\AppInfo\Application; use OCA\Richdocuments\Db\DirectMapper; use OCA\Richdocuments\Exceptions\ExpiredTokenException; use OCA\Richdocuments\Exceptions\UnknownTokenException; @@ -21,6 +22,9 @@ use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\Constants; +use OCP\DirectEditing\IManager as IDirectEditingManager; +use OCP\DirectEditing\RegisterDirectEditorEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; @@ -47,6 +51,8 @@ public function __construct( private TokenManager $tokenManager, private IManager $shareManager, private FederationService $federationService, + private IDirectEditingManager $directEditingManager, + private IEventDispatcher $eventDispatcher, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -55,7 +61,13 @@ public function __construct( /** * @NoAdminRequired * - * Init a direct editing session + * Init a direct editing session. + * + * @deprecated Use the server's direct editing API at + * POST /ocs/v2.php/apps/files/api/v1/directEditing/open with + * editorId=richdocuments. This endpoint is kept for + * backwards compatibility with older clients and now delegates + * to {@see \OCP\DirectEditing\IManager}. * * @param int $fileId * @return DataResponse @@ -74,12 +86,16 @@ public function createDirect($fileId) { throw new OCSBadRequestException('Cannot view folder'); } - $direct = $this->directMapper->newDirect($this->userId, $fileId); + $path = $userFolder->getRelativePath($node->getPath()); + + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + /** @psalm-suppress UndefinedInterfaceMethod IManager does not expose open() but the concrete Manager does, same pattern as files-app DirectEditingController */ + $token = $this->directEditingManager->open($path, Application::APPNAME, $node->getId()); return new DataResponse([ - 'url' => $this->urlGenerator->linkToRouteAbsolute('richdocuments.directView.show', [ - 'token' => $direct->getToken() - ]) + 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', [ + 'token' => $token, + ]), ]); } catch (NotFoundException) { throw new OCSNotFoundException(); @@ -246,6 +262,12 @@ public function updateGuestName(string $access_token, string $guestName): DataRe * @NoAdminRequired * @PublicPage * + * @deprecated Use the server's direct editing API at + * GET /ocs/v2.php/apps/files/api/v1/directEditing/templates/richdocuments/{creatorId}. + * This endpoint is kept for backwards compatibility and reads + * from the same {@see \OCA\Richdocuments\TemplateManager} as + * the new flow. + * * @param string $type The template type * @return DataResponse * @throws OCSBadRequestException @@ -261,6 +283,12 @@ public function getTemplates($type) { /** * @NoAdminRequired * + * @deprecated Use the server's direct editing API at + * POST /ocs/v2.php/apps/files/api/v1/directEditing/create with + * editorId=richdocuments. This endpoint is kept for + * backwards compatibility with older clients and now delegates + * to {@see \OCP\DirectEditing\IManager}. + * * @param string $path Where to create the document * @param int $template The template id */ @@ -279,17 +307,29 @@ public function createFromTemplate($path, $template) { $userFolder = $this->rootFolder->getUserFolder($this->userId); $folder = isset($info['dirname']) ? $userFolder->get($info['dirname']) : $userFolder; $name = $folder->getNonExistingName($info['basename']); - $file = $folder->newFile($name); - $direct = $this->directMapper->newDirect($this->userId, $file->getId(), $template); + $dirPath = isset($info['dirname']) ? rtrim($info['dirname'], '/') : ''; + $targetPath = $dirPath === '' ? '/' . $name : $dirPath . '/' . $name; + + $extension = $info['extension'] ?? ''; + $creatorId = $this->manager->getTemplateTypeForExtension($extension); + if ($creatorId === null) { + throw new OCSBadRequestException('Unsupported file extension'); + } + + $this->eventDispatcher->dispatchTyped(new RegisterDirectEditorEvent($this->directEditingManager)); + /** @psalm-suppress InvalidArgument IManager::create accepts mixed templateId despite an outdated nullable docblock */ + $token = $this->directEditingManager->create($targetPath, Application::APPNAME, $creatorId, (string)$template); return new DataResponse([ - 'url' => $this->urlGenerator->linkToRouteAbsolute('richdocuments.directView.show', [ - 'token' => $direct->getToken() - ]) + 'url' => $this->urlGenerator->linkToRouteAbsolute('files.DirectEditingView.edit', [ + 'token' => $token, + ]), ]); } catch (NotFoundException) { throw new OCSNotFoundException(); + } catch (OCSBadRequestException $e) { + throw $e; } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); throw new OCSException('Failed to create new file from template.'); diff --git a/lib/DirectEditing/AbstractOfficeCreator.php b/lib/DirectEditing/AbstractOfficeCreator.php new file mode 100644 index 0000000000..1c559fc33d --- /dev/null +++ b/lib/DirectEditing/AbstractOfficeCreator.php @@ -0,0 +1,93 @@ +getTemplateType(); + } + + #[\Override] + public function getExtension(): string { + return $this->isOoxml() ? $this->getOoxmlExtension() : $this->getOdfExtension(); + } + + #[\Override] + public function getMimetype(): string { + return $this->isOoxml() ? $this->getOoxmlMimetype() : $this->getOdfMimetype(); + } + + #[\Override] + public function getTemplates(): array { + $templates = $this->templateManager->getAllFormatted($this->getTemplateType()); + + return array_map( + fn (array $template): ATemplate => new OfficeTemplate( + (string)$template['id'], + $template['name'], + $template['preview'] ?? '', + ), + $templates, + ); + } + + #[\Override] + public function create(File $file, ?string $creatorId = null, ?string $templateId = null): void { + $templateFile = null; + if ($templateId !== null && $templateId !== '' && $templateId !== 'empty' && ctype_digit($templateId)) { + try { + $templateFile = $this->templateManager->get((int)$templateId); + } catch (\Throwable) { + $templateFile = null; + } + } + + $this->eventDispatcher->dispatchTyped(new FileCreatedFromTemplateEvent($templateFile, $file, [])); + } + + protected function isOoxml(): bool { + return $this->config->getAppValue(Application::APPNAME, 'doc_format', 'ooxml') === 'ooxml'; + } +} diff --git a/lib/DirectEditing/CreateDocument.php b/lib/DirectEditing/CreateDocument.php new file mode 100644 index 0000000000..94956b88a1 --- /dev/null +++ b/lib/DirectEditing/CreateDocument.php @@ -0,0 +1,43 @@ +l10n->t('New document'); + } + + #[\Override] + protected function getTemplateType(): string { + return 'document'; + } + + #[\Override] + protected function getOdfExtension(): string { + return 'odt'; + } + + #[\Override] + protected function getOdfMimetype(): string { + return 'application/vnd.oasis.opendocument.text'; + } + + #[\Override] + protected function getOoxmlExtension(): string { + return 'docx'; + } + + #[\Override] + protected function getOoxmlMimetype(): string { + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + } +} diff --git a/lib/DirectEditing/CreateDrawing.php b/lib/DirectEditing/CreateDrawing.php new file mode 100644 index 0000000000..eb81c7d00a --- /dev/null +++ b/lib/DirectEditing/CreateDrawing.php @@ -0,0 +1,43 @@ +l10n->t('New diagram'); + } + + #[\Override] + protected function getTemplateType(): string { + return 'drawing'; + } + + #[\Override] + protected function getOdfExtension(): string { + return 'odg'; + } + + #[\Override] + protected function getOdfMimetype(): string { + return 'application/vnd.oasis.opendocument.graphics'; + } + + #[\Override] + protected function getOoxmlExtension(): string { + return 'odg'; + } + + #[\Override] + protected function getOoxmlMimetype(): string { + return 'application/vnd.oasis.opendocument.graphics'; + } +} diff --git a/lib/DirectEditing/CreatePresentation.php b/lib/DirectEditing/CreatePresentation.php new file mode 100644 index 0000000000..5d572c72d5 --- /dev/null +++ b/lib/DirectEditing/CreatePresentation.php @@ -0,0 +1,43 @@ +l10n->t('New presentation'); + } + + #[\Override] + protected function getTemplateType(): string { + return 'presentation'; + } + + #[\Override] + protected function getOdfExtension(): string { + return 'odp'; + } + + #[\Override] + protected function getOdfMimetype(): string { + return 'application/vnd.oasis.opendocument.presentation'; + } + + #[\Override] + protected function getOoxmlExtension(): string { + return 'pptx'; + } + + #[\Override] + protected function getOoxmlMimetype(): string { + return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + } +} diff --git a/lib/DirectEditing/CreateSpreadsheet.php b/lib/DirectEditing/CreateSpreadsheet.php new file mode 100644 index 0000000000..f3f6c2728f --- /dev/null +++ b/lib/DirectEditing/CreateSpreadsheet.php @@ -0,0 +1,43 @@ +l10n->t('New spreadsheet'); + } + + #[\Override] + protected function getTemplateType(): string { + return 'spreadsheet'; + } + + #[\Override] + protected function getOdfExtension(): string { + return 'ods'; + } + + #[\Override] + protected function getOdfMimetype(): string { + return 'application/vnd.oasis.opendocument.spreadsheet'; + } + + #[\Override] + protected function getOoxmlExtension(): string { + return 'xlsx'; + } + + #[\Override] + protected function getOoxmlMimetype(): string { + return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + } +} diff --git a/lib/DirectEditing/OfficeDirectEditor.php b/lib/DirectEditing/OfficeDirectEditor.php new file mode 100644 index 0000000000..0be73ad6ca --- /dev/null +++ b/lib/DirectEditing/OfficeDirectEditor.php @@ -0,0 +1,94 @@ +capabilitiesService->getProductName(); + } + + #[\Override] + public function getMimetypes(): array { + return $this->capabilities->getDefaultMimetypes(); + } + + #[\Override] + public function getMimetypesOptional(): array { + return $this->capabilities->getOptionalMimetypes(); + } + + #[\Override] + public function getCreators(): array { + return [ + $this->createDocument, + $this->createSpreadsheet, + $this->createPresentation, + $this->createDrawing, + ]; + } + + #[\Override] + public function isSecure(): bool { + return true; + } + + #[\Override] + public function open(IToken $token): Response { + $token->useTokenScope(); + $userId = $token->getUser(); + // useTokenScope() only sets OC_User; populate IUserSession + FS scope so + // downstream services (TokenManager, FederationService) see the user. + $this->userScopeService->setUserScope($userId); + $this->userScopeService->setFilesystemScope($userId); + + try { + $file = $token->getFile(); + return $this->viewService->render($file, $userId); + } catch (NotFoundException $e) { + $this->logger->error('Failed to open direct editing token: file not found', ['exception' => $e]); + return new NotFoundResponse(); + } catch (\Throwable $e) { + $this->logger->error('Failed to open direct editing token', ['exception' => $e]); + return new NotFoundResponse(); + } + } +} diff --git a/lib/DirectEditing/OfficeTemplate.php b/lib/DirectEditing/OfficeTemplate.php new file mode 100644 index 0000000000..3b7c0d896d --- /dev/null +++ b/lib/DirectEditing/OfficeTemplate.php @@ -0,0 +1,37 @@ +id; + } + + #[\Override] + public function getTitle(): string { + return $this->title; + } + + #[\Override] + public function getPreview(): string { + return $this->preview; + } +} diff --git a/lib/Listener/RegisterDirectEditorListener.php b/lib/Listener/RegisterDirectEditorListener.php new file mode 100644 index 0000000000..2126043794 --- /dev/null +++ b/lib/Listener/RegisterDirectEditorListener.php @@ -0,0 +1,32 @@ + */ +final class RegisterDirectEditorListener implements IEventListener { + + public function __construct( + private OfficeDirectEditor $editor, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof RegisterDirectEditorEvent) { + return; + } + $event->register($this->editor); + } +} diff --git a/lib/Service/DirectEditingViewService.php b/lib/Service/DirectEditingViewService.php new file mode 100644 index 0000000000..f256f3481a --- /dev/null +++ b/lib/Service/DirectEditingViewService.php @@ -0,0 +1,106 @@ +templateManager->setUserId($userId); + $this->templateManager->setTemplateSource($targetFileId, $templateFileId); + } + + public function render(File $file, string $userId, bool $isDirect = true): Response { + $federatedUrl = $this->federationService->getRemoteRedirectURL($file, null, null, $userId); + if ($federatedUrl !== null) { + return new RedirectResponse($federatedUrl); + } + + $this->templateManager->setUserId($userId); + $templateSource = $this->templateManager->getTemplateSource($file->getId()); + if ($templateSource !== null) { + $wopi = $this->tokenManager->generateWopiTokenForTemplate( + $templateSource, + $file->getId(), + $userId, + false, + $isDirect, + ); + } else { + $wopi = $this->tokenManager->generateWopiToken((string)$file->getId(), null, $userId, $isDirect); + } + + $urlSrc = $this->tokenManager->getUrlSrc($file); + + $folder = $this->rootFolder->getUserFolder($userId); + $relativePath = $folder->getRelativePath($file->getPath()) ?? $file->getName(); + + $params = [ + 'permissions' => $file->getPermissions(), + 'title' => basename($relativePath), + 'fileId' => $wopi->getFileid() . '_' . $this->config->getSystemValue('instanceid'), + 'token' => $wopi->getToken(), + 'token_ttl' => $wopi->getExpiry(), + 'urlsrc' => $urlSrc, + 'path' => $relativePath, + 'direct' => $isDirect, + 'userId' => $userId, + ]; + + return $this->documentTemplateResponse($wopi, $params); + } +} diff --git a/lib/Service/FederationService.php b/lib/Service/FederationService.php index e37c5e102c..c9b30a0801 100644 --- a/lib/Service/FederationService.php +++ b/lib/Service/FederationService.php @@ -188,7 +188,7 @@ public function getRemoteFileDetails(string $remote, string $remoteToken) { * @throws NotFoundException * @throws InvalidPathException */ - public function getRemoteRedirectURL(File $item, ?Direct $direct = null, ?IShare $share = null) { + public function getRemoteRedirectURL(File $item, ?Direct $direct = null, ?IShare $share = null, ?string $userId = null) { if (!$item->getStorage()->instanceOfStorage(SharingExternalStorage::class)) { return null; } @@ -199,7 +199,8 @@ public function getRemoteRedirectURL(File $item, ?Direct $direct = null, ?IShare if ($remoteCollabora !== '') { $shareToken = $share ? $share->getToken() : null; - $wopi = $this->tokenManager->newInitiatorToken($remote, $item, $shareToken, ($direct !== null), ($direct ? $direct->getUid() : null)); + $initiatorUid = $direct ? $direct->getUid() : $userId; + $wopi = $this->tokenManager->newInitiatorToken($remote, $item, $shareToken, ($direct !== null), $initiatorUid); $initiatorServer = $this->urlGenerator->getAbsoluteURL('/'); $initiatorToken = $wopi->getToken(); diff --git a/tests/features/bootstrap/DirectContext.php b/tests/features/bootstrap/DirectContext.php index a2c8bb96c7..06ab86ceb7 100644 --- a/tests/features/bootstrap/DirectContext.php +++ b/tests/features/bootstrap/DirectContext.php @@ -143,7 +143,10 @@ public function theDirectEditingLinkIsOnlyValidOnce() { throw new \Exception('No existing direct editing link found to be checked'); } $result = $this->openDirectEditingLink(); - Assert::assertEquals(403, $result->getStatusCode()); + // 403: legacy /apps/richdocuments/direct/ rejects the missing token. + // 404: server's /apps/files/directEditing/ returns NotFoundResponse + // once the token has been accessed. + Assert::assertContains($result->getStatusCode(), [403, 404]); } private function openDirectEditingLink() {