diff --git a/lib/Controller/WopiController.php b/lib/Controller/WopiController.php index 60ee8d393f..4fa4db6ab5 100644 --- a/lib/Controller/WopiController.php +++ b/lib/Controller/WopiController.php @@ -725,151 +725,220 @@ public function putFile( } /** - * Given an access token and a fileId, replaces the files with the request body. - * Expects a valid token in access_token parameter. - * Just actually routes to the PutFile, the implementation of PutFile - * handles both saving and saving as.* Given an access token and a fileId, replaces the files with the request body. + * Implements WOPI File operations: + * - `Lock` + * - `GetLock` + * - `Unlock` + * - `RefreshLock` + * - `PutRelativeFile` ("save as") + * - `RenameFile` * - * FIXME Cleanup this code as is a lot of shared logic between putFile and putRelativeFile + * Operation to execute is determined via `X-WOPI-Override` header value. + * + * https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile + * https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock + * https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock + * https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock + * https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/renamefile */ #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] #[FrontpageRoute(verb: 'POST', url: 'wopi/files/{fileId}')] - public function postFile( - string $fileId, - #[\SensitiveParameter] - string $access_token, - ): JSONResponse { + public function postFile(string $fileId, #[\SensitiveParameter] string $access_token): JSONResponse { + $resolvedContext = $this->resolveWopiWriteContext($fileId, $access_token); + if ($resolvedContext instanceof JSONResponse) { + return $resolvedContext; + } + + $wopi = $resolvedContext['wopi']; + $override = $this->request->getHeader('X-WOPI-Override'); + $lock = $this->request->getHeader('X-WOPI-Lock'); + + // TODO: add guard against empty $lock value; required for all operations other than PutRelativeFile ("save as") + if (in_array($override, ['LOCK', 'UNLOCK', 'REFRESH_LOCK', 'GET_LOCK'], true)) { + switch ($override) { + case 'LOCK': + return $this->lock($wopi, $lock); + case 'UNLOCK': + return $this->unlock($wopi, $lock); + case 'REFRESH_LOCK': + return $this->refreshLock($wopi, $lock); + case 'GET_LOCK': + return $this->getLock($wopi, $lock); + } + } + try { - $wopiOverride = $this->request->getHeader('X-WOPI-Override'); - $wopiLock = $this->request->getHeader('X-WOPI-Lock'); - [$fileId, , ] = Helper::parseFileId($fileId); - $wopi = $this->wopiMapper->getWopiForToken($access_token); - if ((int)$fileId !== $wopi->getFileid()) { - return new JSONResponse([], Http::STATUS_FORBIDDEN); + if ($override === 'RENAME_FILE') { + return $this->renameWopiFile($wopi); + } elseif ($override === 'PUT_RELATIVE') { + return $this->putRelativeWopiFile($wopi); } - } catch (UnknownTokenException $e) { - $this->logger->debug($e->getMessage(), ['exception' => $e]); - return new JSONResponse([], Http::STATUS_FORBIDDEN); - } catch (ExpiredTokenException $e) { - $this->logger->debug($e->getMessage(), ['exception' => $e]); - return new JSONResponse([], Http::STATUS_UNAUTHORIZED); + + return new JSONResponse(['error' => 'Unsupported WOPI override'], Http::STATUS_BAD_REQUEST); + } catch (NotFoundException $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - return new JSONResponse([], Http::STATUS_FORBIDDEN); + return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); } + } - if (!$wopi->getCanwrite()) { - return new JSONResponse([], Http::STATUS_FORBIDDEN); + /** + * Implements WOPI RenameFile. + */ + private function renameWopiFile(Wopi $wopi): JSONResponse { + $file = $this->getFileForWopiToken($wopi); + if (!($file instanceof File)) { + throw new NotFoundException('No valid file found'); } - switch ($wopiOverride) { - case 'LOCK': - return $this->lock($wopi, $wopiLock); - case 'UNLOCK': - return $this->unlock($wopi, $wopiLock); - case 'REFRESH_LOCK': - return $this->refreshLock($wopi, $wopiLock); - case 'GET_LOCK': - return $this->getLock($wopi, $wopiLock); - case 'RENAME_FILE': - break; //FIXME: Move to function - default: - break; //FIXME: Move to function and add error for unsupported method - } + $requestedName = $this->request->getHeader('X-WOPI-RequestedName'); + $requestedName = mb_convert_encoding($requestedName, 'utf-8', 'utf-7') . '.' . $file->getExtension(); + $basePath = dirname($file->getPath()); + $targetPath = $this->normalizePath($requestedName, $basePath); - $isRenameFile = ($this->request->getHeader('X-WOPI-Override') === 'RENAME_FILE'); + if ($targetPath === '') { + return new JSONResponse([ + 'status' => 'error', + 'message' => 'Cannot rename the file', + ], Http::STATUS_BAD_REQUEST); + } - // Unless the editor is empty (public link) we modify the files as the current editor - $editor = $wopi->getEditorUid(); - $isPublic = $editor === null && !$wopi->isRemoteToken(); - if ($isPublic) { - $editor = $wopi->getOwnerUid(); + if (!$this->rootFolder->nodeExists(dirname($targetPath))) { + $this->rootFolder->newFolder(dirname($targetPath)); } - try { - // the new file needs to be installed in the current user dir - $userFolder = $this->rootFolder->getUserFolder($editor); + $finalPath = $this->rootFolder->getNonExistingName($targetPath); - if ($isRenameFile) { - // the new file needs to be installed in the current user dir - $file = $this->getFileForWopiToken($wopi); + $this->lockManager->runInScope(new LockContext( + $file, + ILock::TYPE_APP, + Application::APPNAME + ), function () use (&$file, $finalPath): void { + $file = $file->move($finalPath); + }); - $suggested = $this->request->getHeader('X-WOPI-RequestedName'); - $suggested = mb_convert_encoding($suggested, 'utf-8', 'utf-7') . '.' . $file->getExtension(); + return new JSONResponse([ + 'Name' => pathinfo($file->getName(), PATHINFO_FILENAME), + ], Http::STATUS_OK); + } - $path = $this->normalizePath($suggested, dirname($file->getPath())); + /** + * Implements WOPI PutRelativeFile ("save as"). + */ + private function putRelativeWopiFile(Wopi $wopi): JSONResponse { + $file = $this->getFileForWopiToken($wopi); + if (!($file instanceof File)) { + throw new NotFoundException('No valid file found'); + } - if ($path === '') { - return new JSONResponse([ - 'status' => 'error', - 'message' => 'Cannot rename the file' - ]); - } + $isPublic = false; + $editor = $wopi->getEditorUid(); + if ($editor === null && !$wopi->isRemoteToken()) { + // Public links have no editor UID; use the owner so the file is created in the owner's space. + $editor = $wopi->getOwnerUid(); + $isPublic = true; // tracked to determine appropriate parent path + } - // create the folder first - if (!$this->rootFolder->nodeExists(dirname($path))) { - $this->rootFolder->newFolder(dirname($path)); - } + // TODO: See encryption detection/handling in putFile() to see if it's applicable here too - // create a unique new file - $path = $this->rootFolder->getNonExistingName($path); - $this->lockManager->runInScope(new LockContext( - $this->getFileForWopiToken($wopi), - ILock::TYPE_APP, - Application::APPNAME - ), function () use (&$file, $path): void { - $file = $file->move($path); - }); - } else { - $file = $this->getFileForWopiToken($wopi); + $userFolder = $this->rootFolder->getUserFolder($editor); - $suggested = $this->request->getHeader('X-WOPI-SuggestedTarget'); - $suggested = mb_convert_encoding($suggested, 'utf-8', 'utf-7'); + $suggestedTarget = $this->request->getHeader('X-WOPI-SuggestedTarget'); + $suggestedTarget = mb_convert_encoding($suggestedTarget, 'utf-8', 'utf-7'); - $parent = $isPublic ? dirname($file->getPath()) : $userFolder->getPath(); - $path = $this->normalizePath($suggested, $parent); + $basePath = $isPublic ? dirname($file->getPath()) : $userFolder->getPath(); + $targetPath = $this->normalizePath($suggestedTarget, $basePath); - // create the folder first - if (!$this->rootFolder->nodeExists(dirname($path))) { - $this->rootFolder->newFolder(dirname($path)); - } + if ($targetPath === '') { + return new JSONResponse([ + 'status' => 'error', + 'message' => 'Cannot create the file', + ], Http::STATUS_BAD_REQUEST); + } - // create a unique new file - $path = $this->rootFolder->getNonExistingName($path); - $file = $this->rootFolder->newFile($path); - } + if (!$this->rootFolder->nodeExists(dirname($targetPath))) { + $this->rootFolder->newFolder(dirname($targetPath)); + } - $content = fopen('php://input', 'rb'); - // Set the user to register the change under his name - $this->userScopeService->setUserScope($editor); - $this->userScopeService->setFilesystemScope($editor); + $finalPath = $this->rootFolder->getNonExistingName($targetPath); + $file = $this->rootFolder->newFile($finalPath); - try { - $this->wrappedFilesystemOperation($wopi, fn () => $file->putContent($content)); - } catch (LockedException) { - return new JSONResponse(['message' => 'File locked'], Http::STATUS_INTERNAL_SERVER_ERROR); - } + $content = fopen('php://input', 'rb'); + $this->userScopeService->setUserScope($editor); + $this->userScopeService->setFilesystemScope($editor); - // epub is exception (can be uploaded but not opened so don't try to get access token) - if ($file->getMimeType() == 'application/epub+zip') { - return new JSONResponse(['Name' => $file->getName()], Http::STATUS_OK); - } + try { + $this->wrappedFilesystemOperation($wopi, fn () => $file->putContent($content)); + } catch (LockedException) { + // Should be rare because the target name is unique, but keep a defensive check. + return new JSONResponse(['message' => 'File locked'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + + // EPUB can be uploaded, but not opened, so do not generate a WOPI URL. + if ($file->getMimeType() === 'application/epub+zip') { + return new JSONResponse(['Name' => $file->getName()], Http::STATUS_OK); + } - // generate a token for the new file (the user still has to be - // logged in) - $wopi = $this->tokenManager->generateWopiToken((string)$file->getId(), $wopi->getShare(), $wopi->getEditorUid(), $wopi->getDirect()); + $newWopi = $this->tokenManager->generateWopiToken( + (string)$file->getId(), + $wopi->getShare(), + $wopi->getEditorUid(), + $wopi->getDirect() + ); + + return new JSONResponse([ + 'Name' => $file->getName(), + 'Url' => $this->getWopiUrlForFile($newWopi, $file), + ], Http::STATUS_OK); + } - return new JSONResponse(['Name' => $file->getName(), 'Url' => $this->getWopiUrlForFile($wopi, $file)], Http::STATUS_OK); - } catch (NotFoundException $e) { - $this->logger->warning($e->getMessage(), ['exception' => $e]); - return new JSONResponse([], Http::STATUS_NOT_FOUND); + /** + * Validate and resolve the WOPI write-file context. + * + * @return array{ + * fileId: int, + * instanceId: string, + * version: string, + * templateId: string, + * wopi: Wopi + * }|JSONResponse + */ + private function resolveWopiWriteContext(string $fileId, #[\SensitiveParameter] string $token): array|JSONResponse { + try { + [$fileId, $instanceId, $version, $templateId] = Helper::parseFileId($fileId); + $fileId = (int)$fileId; + $wopi = $this->wopiMapper->getWopiForToken($token); + } catch (UnknownTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } catch (ExpiredTokenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + return new JSONResponse([], Http::STATUS_UNAUTHORIZED); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); - return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR); + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + if (!$wopi->getCanwrite()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); } + + if ($fileId !== $wopi->getFileid()) { + return new JSONResponse([], Http::STATUS_FORBIDDEN); + } + + return [ + 'fileId' => $fileId, + 'instanceId' => $instanceId, + 'version' => $version, + 'templateId' => $templateId, + 'wopi' => $wopi, + ]; } private function normalizePath(string $path, ?string $parent = null): string { diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9e0fe758a0..226c44dc7f 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -77,7 +77,6 @@ -