From 9de78d757e3fc4e998e611c1dbb52c9f60731167 Mon Sep 17 00:00:00 2001 From: "Bernd.Rederlechner@t-systems.com" Date: Mon, 13 Sep 2021 18:16:00 +0200 Subject: [PATCH] S3 direct multipart upload optimisation. The change was needed to avoid doubling of upload time as the original solution does a complete re-read of the S3 object on final MOVE of NextCloud chunked upload. See https://github.com/nextcloud/server/pull/27034 for details. Signed-off-by: Bernd.Rederlechner@t-systems.com --- .../composer/composer/autoload_classmap.php | 2 + .../dav/composer/composer/autoload_static.php | 2 + apps/dav/lib/BackgroundJob/UploadCleanup.php | 10 +- apps/dav/lib/Connector/Sabre/Directory.php | 1 + apps/dav/lib/Connector/Sabre/Node.php | 8 + .../dav/lib/Connector/Sabre/ServerFactory.php | 2 + apps/dav/lib/DAV/CustomPropertiesBackend.php | 17 +- apps/dav/lib/Server.php | 4 + .../lib/Service/CustomPropertiesService.php | 60 +++++ apps/dav/lib/Upload/ChunkingV2Plugin.php | 223 ++++++++++++++++++ apps/dav/lib/Upload/FutureFile.php | 4 + apps/dav/lib/Upload/UploadFile.php | 8 + apps/dav/lib/Upload/UploadFolder.php | 10 +- apps/dav/lib/Upload/UploadHome.php | 20 +- .../Sabre/CustomPropertiesBackendTest.php | 2 + .../unit/DAV/CustomPropertiesBackendTest.php | 15 +- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + .../Files/ObjectStore/ObjectStoreStorage.php | 115 ++++++++- lib/private/Files/ObjectStore/S3.php | 50 +++- lib/private/Files/View.php | 18 ++ .../IObjectStoreMultiPartUpload.php | 54 +++++ .../Files/Storage/IChunkedFileWrite.php | 71 ++++++ 23 files changed, 680 insertions(+), 20 deletions(-) create mode 100644 apps/dav/lib/Service/CustomPropertiesService.php create mode 100644 apps/dav/lib/Upload/ChunkingV2Plugin.php create mode 100644 lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php create mode 100644 lib/public/Files/Storage/IChunkedFileWrite.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 13e78cee71645..7a6d404089043 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -271,6 +271,7 @@ 'OCA\\DAV\\Search\\EventsSearchProvider' => $baseDir . '/../lib/Search/EventsSearchProvider.php', 'OCA\\DAV\\Search\\TasksSearchProvider' => $baseDir . '/../lib/Search/TasksSearchProvider.php', 'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php', + 'OCA\\DAV\\Service\\CustomPropertiesService' => $baseDir . '/../lib/Service/CustomPropertiesService.php', 'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php', 'OCA\\DAV\\Storage\\PublicOwnerWrapper' => $baseDir . '/../lib/Storage/PublicOwnerWrapper.php', 'OCA\\DAV\\SystemTag\\SystemTagMappingNode' => $baseDir . '/../lib/SystemTag/SystemTagMappingNode.php', @@ -283,6 +284,7 @@ 'OCA\\DAV\\Traits\\PrincipalProxyTrait' => $baseDir . '/../lib/Traits/PrincipalProxyTrait.php', 'OCA\\DAV\\Upload\\AssemblyStream' => $baseDir . '/../lib/Upload/AssemblyStream.php', 'OCA\\DAV\\Upload\\ChunkingPlugin' => $baseDir . '/../lib/Upload/ChunkingPlugin.php', + 'OCA\\DAV\\Upload\\ChunkingV2Plugin' => $baseDir . '/../lib/Upload/ChunkingV2Plugin.php', 'OCA\\DAV\\Upload\\CleanupService' => $baseDir . '/../lib/Upload/CleanupService.php', 'OCA\\DAV\\Upload\\FutureFile' => $baseDir . '/../lib/Upload/FutureFile.php', 'OCA\\DAV\\Upload\\RootCollection' => $baseDir . '/../lib/Upload/RootCollection.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 42ba59e0582e2..66c776384d61f 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -286,6 +286,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Search\\EventsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/EventsSearchProvider.php', 'OCA\\DAV\\Search\\TasksSearchProvider' => __DIR__ . '/..' . '/../lib/Search/TasksSearchProvider.php', 'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php', + 'OCA\\DAV\\Service\\CustomPropertiesService' => __DIR__ . '/..' . '/../lib/Service/CustomPropertiesService.php', 'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php', 'OCA\\DAV\\Storage\\PublicOwnerWrapper' => __DIR__ . '/..' . '/../lib/Storage/PublicOwnerWrapper.php', 'OCA\\DAV\\SystemTag\\SystemTagMappingNode' => __DIR__ . '/..' . '/../lib/SystemTag/SystemTagMappingNode.php', @@ -298,6 +299,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Traits\\PrincipalProxyTrait' => __DIR__ . '/..' . '/../lib/Traits/PrincipalProxyTrait.php', 'OCA\\DAV\\Upload\\AssemblyStream' => __DIR__ . '/..' . '/../lib/Upload/AssemblyStream.php', 'OCA\\DAV\\Upload\\ChunkingPlugin' => __DIR__ . '/..' . '/../lib/Upload/ChunkingPlugin.php', + 'OCA\\DAV\\Upload\\ChunkingV2Plugin' => __DIR__ . '/..' . '/../lib/Upload/ChunkingV2Plugin.php', 'OCA\\DAV\\Upload\\CleanupService' => __DIR__ . '/..' . '/../lib/Upload/CleanupService.php', 'OCA\\DAV\\Upload\\FutureFile' => __DIR__ . '/..' . '/../lib/Upload/FutureFile.php', 'OCA\\DAV\\Upload\\RootCollection' => __DIR__ . '/..' . '/../lib/Upload/RootCollection.php', diff --git a/apps/dav/lib/BackgroundJob/UploadCleanup.php b/apps/dav/lib/BackgroundJob/UploadCleanup.php index 70f4d6b95650f..e3d9e3821b1af 100644 --- a/apps/dav/lib/BackgroundJob/UploadCleanup.php +++ b/apps/dav/lib/BackgroundJob/UploadCleanup.php @@ -28,6 +28,7 @@ namespace OCA\DAV\BackgroundJob; use OC\User\NoUserException; +use OCA\DAV\Service\CustomPropertiesService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; use OCP\BackgroundJob\TimedJob; @@ -44,10 +45,14 @@ class UploadCleanup extends TimedJob { /** @var IJobList */ private $jobList; - public function __construct(ITimeFactory $time, IRootFolder $rootFolder, IJobList $jobList) { + /** @var CustomPropertiesService */ + private $customPropertiesService; + + public function __construct(ITimeFactory $time, IRootFolder $rootFolder, IJobList $jobList, CustomPropertiesService $customPropertiesService) { parent::__construct($time); $this->rootFolder = $rootFolder; $this->jobList = $jobList; + $this->customPropertiesService = $customPropertiesService; // Run once a day $this->setInterval(60 * 60 * 24); @@ -71,6 +76,8 @@ protected function run($argument) { $files = $uploadFolder->getDirectoryListing(); + $davPath = 'uploads/' . $uid . '/' . $uploadFolder->getName(); + // Remove if all files have an mtime of more than a day $time = $this->time->getTime() - 60 * 60 * 24; @@ -82,6 +89,7 @@ protected function run($argument) { }, $initial); if ($expire) { + $this->customPropertiesService->delete($uid, $davPath); $uploadFolder->delete(); $this->jobList->remove(self::class, $argument); } diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index 3ae4416d3632f..dd5c25fa52710 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -37,6 +37,7 @@ use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\DAV\Upload\FutureFile; use OCP\Files\FileInfo; use OCP\Files\ForbiddenException; use OCP\Files\InvalidPathException; diff --git a/apps/dav/lib/Connector/Sabre/Node.php b/apps/dav/lib/Connector/Sabre/Node.php index aa03f42dc35c0..4560630e14441 100644 --- a/apps/dav/lib/Connector/Sabre/Node.php +++ b/apps/dav/lib/Connector/Sabre/Node.php @@ -246,6 +246,14 @@ public function getInternalFileId() { return $this->info->getId(); } + public function getInternalPath(): string { + return $this->info->getInternalPath(); + } + + public function getAbsoluteInternalPath(): string { + return $this->info->getPath(); + } + /** * @param string $user * @return int diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 7be24014881c9..ae0595f52bcf8 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -34,6 +34,7 @@ use OC\Files\Node\Folder; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Service\CustomPropertiesService; use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; @@ -204,6 +205,7 @@ public function createServer($baseUri, new \OCA\DAV\DAV\CustomPropertiesBackend( $objectTree, $this->databaseConnection, + \OC::$server->get(CustomPropertiesService::class), $this->userSession->getUser() ) ) diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index 1bd207e0967dd..990de44ffbdf0 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -25,6 +25,7 @@ namespace OCA\DAV\DAV; use OCA\DAV\Connector\Sabre\Node; +use OCA\DAV\Service\CustomPropertiesService; use OCP\IDBConnection; use OCP\IUser; use Sabre\DAV\PropertyStorage\Backend\BackendInterface; @@ -75,6 +76,11 @@ class CustomPropertiesBackend implements BackendInterface { */ private $connection; + /** + * @var CustomPropertiesService + */ + private $customPropertiesService; + /** * @var IUser */ @@ -95,9 +101,11 @@ class CustomPropertiesBackend implements BackendInterface { public function __construct( Tree $tree, IDBConnection $connection, + CustomPropertiesService $customPropertiesService, IUser $user) { $this->tree = $tree; $this->connection = $connection; + $this->customPropertiesService = $customPropertiesService; $this->user = $user; } @@ -171,13 +179,8 @@ public function propPatch($path, PropPatch $propPatch) { * @param string $path path of node for which to delete properties */ public function delete($path) { - $statement = $this->connection->prepare( - 'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?' - ); - $statement->execute([$this->user->getUID(), $this->formatPath($path)]); - $statement->closeCursor(); - - unset($this->userCache[$path]); + $this->customPropertiesService->delete($this->user->getUID(), $path); + unset($this->cache[$path]); } /** diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index f22cf888e1cbf..94dfaed1a3bff 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -63,8 +63,10 @@ use OCA\DAV\Files\BrowserErrorPagePlugin; use OCA\DAV\Files\LazySearchBackend; use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin; +use OCA\DAV\Service\CustomPropertiesService; use OCA\DAV\SystemTag\SystemTagPlugin; use OCA\DAV\Upload\ChunkingPlugin; +use OCA\DAV\Upload\ChunkingV2Plugin; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\SabrePluginEvent; @@ -203,6 +205,7 @@ public function __construct(IRequest $request, $baseUri) { )); $this->server->addPlugin(new CopyEtagHeaderPlugin()); + $this->server->addPlugin(new ChunkingV2Plugin()); $this->server->addPlugin(new ChunkingPlugin()); // allow setup of additional plugins @@ -249,6 +252,7 @@ public function __construct(IRequest $request, $baseUri) { new CustomPropertiesBackend( $this->server->tree, \OC::$server->getDatabaseConnection(), + \OC::$server->get(CustomPropertiesService::class), \OC::$server->getUserSession()->getUser() ) ) diff --git a/apps/dav/lib/Service/CustomPropertiesService.php b/apps/dav/lib/Service/CustomPropertiesService.php new file mode 100644 index 0000000000000..cf5f9b8af8da3 --- /dev/null +++ b/apps/dav/lib/Service/CustomPropertiesService.php @@ -0,0 +1,60 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +namespace OCA\DAV\Service; + +use OCP\IDBConnection; + +class CustomPropertiesService { + + /** @var IDBConnection */ + private $connection; + + public function __construct(IDBConnection $connection) { + $this->connection = $connection; + } + + public function delete(string $userId, string $path): void { + $statement = $this->connection->prepare( + 'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?' + ); + $result = $statement->execute([$userId, $this->formatPath($path)]); + $result->closeCursor(); + } + + /** + * long paths are hashed to ensure they fit in the database + * + * @param string $path + * @return string + */ + private function formatPath(string $path): string { + if (strlen($path) > 250) { + return sha1($path); + } + return $path; + } +} diff --git a/apps/dav/lib/Upload/ChunkingV2Plugin.php b/apps/dav/lib/Upload/ChunkingV2Plugin.php new file mode 100644 index 0000000000000..66131b17906a6 --- /dev/null +++ b/apps/dav/lib/Upload/ChunkingV2Plugin.php @@ -0,0 +1,223 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\DAV\Upload; + +use OC\Files\View; +use OCA\DAV\Connector\Sabre\Directory; +use OCP\Files\Storage\IChunkedFileWrite; +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageInvalidException; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\Uri; + +class ChunkingV2Plugin extends ServerPlugin { + + /** @var Server */ + private $server; + /** @var UploadFolder */ + private $uploadFolder; + + private const TEMP_TARGET = '.target'; + + private const OBJECT_UPLOAD_TARGET = '{http://nextcloud.org/ns}upload-target'; + private const OBJECT_UPLOAD_CHUNKTOKEN = '{http://nextcloud.org/ns}upload-chunktoken'; + + private const DESTINATION_HEADER = 'X-Chunking-Destination'; + + /** + * @inheritdoc + */ + public function initialize(Server $server) { + $server->on('afterMethod:MKCOL', [$this, 'beforeMkcol']); + // 200 priority to call after the custom properties backend is registered + $server->on('beforeMethod:PUT', [$this, 'beforePut'], 200); + $server->on('beforeMethod:DELETE', [$this, 'beforeDelete'], 200); + $server->on('beforeMove', [$this, 'beforeMove'], 90); + + $this->server = $server; + } + + /** + * @param string $path + * @param bool $createIfNotExists + * @return FutureFile|UploadFile|\Sabre\DAV\ICollection|\Sabre\DAV\INode + */ + private function getTargetFile(string $path, bool $createIfNotExists = false) { + try { + $targetFile = $this->server->tree->getNodeForPath($path); + } catch (NotFound $e) { + if ($createIfNotExists) { + $this->uploadFolder->createFile(self::TEMP_TARGET); + } + $targetFile = $this->uploadFolder->getChild(self::TEMP_TARGET); + } + return $targetFile; + } + + public function beforeMkcol(RequestInterface $request, ResponseInterface $response): bool { + $this->uploadFolder = $this->server->tree->getNodeForPath($request->getPath()); + try { + $this->checkPrerequisites(); + $storage = $this->getStorage(); + } catch (StorageInvalidException | BadRequest $e) { + return true; + } + + $targetPath = $this->server->httpRequest->getHeader(self::DESTINATION_HEADER); + if (!$targetPath) { + return true; + } + + $targetFile = $this->getTargetFile($targetPath, true); + + $uploadId = $storage->beginChunkedFile($targetFile->getInternalPath()); + + // DAV properties on the UploadFolder are used in order to properly cleanup stale chunked file writes and to persist the target path + $this->server->updateProperties($request->getPath(), [ + self::OBJECT_UPLOAD_CHUNKTOKEN => $uploadId, + self::OBJECT_UPLOAD_TARGET => $targetPath, + ]); + + $response->setStatus(201); + return true; + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): bool { + $this->uploadFolder = $this->server->tree->getNodeForPath(dirname($request->getPath())); + try { + $this->checkPrerequisites(); + $storage = $this->getStorage(); + } catch (StorageInvalidException | BadRequest $e) { + return true; + } + + $properties = $this->server->getProperties(dirname($request->getPath()) . '/', [ self::OBJECT_UPLOAD_CHUNKTOKEN, self::OBJECT_UPLOAD_TARGET ]); + $targetPath = $properties[self::OBJECT_UPLOAD_TARGET]; + $uploadId = $properties[self::OBJECT_UPLOAD_CHUNKTOKEN]; + $partId = (int)basename($request->getPath()); + + if (!($partId >= 1 && $partId <= 10000)) { + throw new BadRequest('Invalid chunk id'); + } + + $targetFile = $this->getTargetFile($targetPath); + $stream = $request->getBodyAsStream(); + $storage->putChunkedFilePart($targetFile->getInternalPath(), $uploadId, (string)$partId, $stream, (int)$request->getHeader('Content-Length')); + + $response->setStatus(201); + return false; + } + + public function beforeMove($sourcePath, $destination): bool { + $this->uploadFolder = $this->server->tree->getNodeForPath(dirname($sourcePath)); + try { + $this->checkPrerequisites(); + $this->getStorage(); + } catch (StorageInvalidException | BadRequest $e) { + return true; + } + $properties = $this->server->getProperties(dirname($sourcePath) . '/', [ self::OBJECT_UPLOAD_CHUNKTOKEN, self::OBJECT_UPLOAD_TARGET ]); + $targetPath = $properties[self::OBJECT_UPLOAD_TARGET]; + $uploadId = $properties[self::OBJECT_UPLOAD_CHUNKTOKEN]; + + $targetFile = $this->getTargetFile($targetPath); + + [$destinationDir, $destinationName] = Uri\split($destination); + /** @var Directory $destinationParent */ + $destinationParent = $this->server->tree->getNodeForPath($destinationDir); + $destinationExists = $destinationParent->childExists($destinationName); + + $rootView = new View(); + $rootView->writeChunkedFile($targetFile->getAbsoluteInternalPath(), $uploadId); + if (!$destinationExists) { + $destinationInView = $destinationParent->getFileInfo()->getPath() . '/' . $destinationName; + $rootView->rename($targetFile->getAbsoluteInternalPath(), $destinationInView); + } + + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if ($sourceNode instanceof FutureFile) { + $this->uploadFolder->delete(); + } + + $this->server->emit('afterMove', [$sourcePath, $destination]); + $this->server->emit('afterUnbind', [$sourcePath]); + $this->server->emit('afterBind', [$destination]); + + $response = $this->server->httpResponse; + $response->setHeader('Content-Length', '0'); + $response->setStatus($destinationExists ? 204 : 201); + return false; + } + + public function beforeDelete(RequestInterface $request, ResponseInterface $response) { + $this->uploadFolder = $this->server->tree->getNodeForPath($request->getPath()); + try { + if (!$this->uploadFolder instanceof UploadFolder) { + return true; + } + $storage = $this->getStorage(); + } catch (StorageInvalidException | BadRequest $e) { + return true; + } + + $properties = $this->server->getProperties($request->getPath() . '/', [ self::OBJECT_UPLOAD_CHUNKTOKEN, self::OBJECT_UPLOAD_TARGET ]); + $targetPath = $properties[self::OBJECT_UPLOAD_TARGET]; + $uploadId = $properties[self::OBJECT_UPLOAD_CHUNKTOKEN]; + if (!$targetPath || !$uploadId) { + return true; + } + $targetFile = $this->getTargetFile($targetPath); + $storage->cancelChunkedFile($targetFile->getInternalPath(), $uploadId); + return true; + } + + /** @throws BadRequest */ + private function checkPrerequisites(): void { + if (!$this->uploadFolder instanceof UploadFolder || !$this->server->httpRequest->getHeader(self::DESTINATION_HEADER)) { + throw new BadRequest('Chunking destination header not set'); + } + } + + /** + * @return IChunkedFileWrite + * @throws BadRequest + * @throws StorageInvalidException + */ + private function getStorage(): IStorage { + $this->checkPrerequisites(); + $storage = $this->uploadFolder->getStorage(); + if (!$storage->instanceOfStorage(IChunkedFileWrite::class)) { + throw new StorageInvalidException('Storage does not support chunked file write'); + } + /** @var IChunkedFileWrite $storage */ + return $storage; + } +} diff --git a/apps/dav/lib/Upload/FutureFile.php b/apps/dav/lib/Upload/FutureFile.php index eba550a62daca..d9534519f5280 100644 --- a/apps/dav/lib/Upload/FutureFile.php +++ b/apps/dav/lib/Upload/FutureFile.php @@ -66,6 +66,10 @@ public function get() { return AssemblyStream::wrap($nodes); } + public function getPath() { + return $this->root->getFileInfo()->getInternalPath() . '/.file'; + } + /** * @inheritdoc */ diff --git a/apps/dav/lib/Upload/UploadFile.php b/apps/dav/lib/Upload/UploadFile.php index 49a2fadecf6c8..f68f2d2abf898 100644 --- a/apps/dav/lib/Upload/UploadFile.php +++ b/apps/dav/lib/Upload/UploadFile.php @@ -72,4 +72,12 @@ public function setName($name) { public function getLastModified() { return $this->file->getLastModified(); } + + public function getInternalPath(): string { + return $this->file->getInternalPath(); + } + + public function getAbsoluteInternalPath(): string { + return $this->file->getFileInfo()->getPath(); + } } diff --git a/apps/dav/lib/Upload/UploadFolder.php b/apps/dav/lib/Upload/UploadFolder.php index bb7c494cee356..e6c9525940e72 100644 --- a/apps/dav/lib/Upload/UploadFolder.php +++ b/apps/dav/lib/Upload/UploadFolder.php @@ -25,6 +25,7 @@ namespace OCA\DAV\Upload; use OCA\DAV\Connector\Sabre\Directory; +use OCP\Files\Storage\IStorage; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\ICollection; @@ -34,10 +35,13 @@ class UploadFolder implements ICollection { private $node; /** @var CleanupService */ private $cleanupService; + /** @var IStorage */ + private $storage; - public function __construct(Directory $node, CleanupService $cleanupService) { + public function __construct(Directory $node, CleanupService $cleanupService, IStorage $storage) { $this->node = $node; $this->cleanupService = $cleanupService; + $this->storage = $storage; } public function createFile($name, $data = null) { @@ -94,4 +98,8 @@ public function setName($name) { public function getLastModified() { return $this->node->getLastModified(); } + + public function getStorage() { + return $this->storage; + } } diff --git a/apps/dav/lib/Upload/UploadHome.php b/apps/dav/lib/Upload/UploadHome.php index 35d47b6a82aba..15401a2c98bc3 100644 --- a/apps/dav/lib/Upload/UploadHome.php +++ b/apps/dav/lib/Upload/UploadHome.php @@ -55,12 +55,12 @@ public function createDirectory($name) { } public function getChild($name): UploadFolder { - return new UploadFolder($this->impl()->getChild($name), $this->cleanupService); + return new UploadFolder($this->impl()->getChild($name), $this->cleanupService, $this->getStorage()); } public function getChildren(): array { return array_map(function ($node) { - return new UploadFolder($node, $this->cleanupService); + return new UploadFolder($node, $this->cleanupService, $this->getStorage()); }, $this->impl()->getChildren()); } @@ -89,14 +89,24 @@ public function getLastModified() { * @return Directory */ private function impl() { + $view = $this->getView(); + $rootInfo = $view->getFileInfo(''); + return new Directory($view, $rootInfo); + } + + private function getView() { $rootView = new View(); $user = \OC::$server->getUserSession()->getUser(); Filesystem::initMountPoints($user->getUID()); if (!$rootView->file_exists('/' . $user->getUID() . '/uploads')) { $rootView->mkdir('/' . $user->getUID() . '/uploads'); } - $view = new View('/' . $user->getUID() . '/uploads'); - $rootInfo = $view->getFileInfo(''); - return new Directory($view, $rootInfo); + return new View('/' . $user->getUID() . '/uploads'); + } + + private function getStorage() { + $view = $this->getView(); + $storage = $view->getFileInfo('')->getStorage(); + return $storage; } } diff --git a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php index 48658f3ffa36f..5e5e9077ab03e 100644 --- a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php @@ -38,6 +38,7 @@ use OCA\DAV\Connector\Sabre\Directory; use OCA\DAV\Connector\Sabre\File; +use OCA\DAV\Service\CustomPropertiesService; use OCP\IUser; use Sabre\DAV\Tree; @@ -89,6 +90,7 @@ protected function setUp(): void { $this->plugin = new \OCA\DAV\DAV\CustomPropertiesBackend( $this->tree, \OC::$server->getDatabaseConnection(), + $this->createMock(CustomPropertiesService::class), $this->user ); } diff --git a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php index 2e7939aa6144f..391703112e1d6 100644 --- a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php +++ b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php @@ -28,8 +28,10 @@ namespace OCA\DAV\Tests\DAV; use OCA\DAV\DAV\CustomPropertiesBackend; +use OCA\DAV\Service\CustomPropertiesService; use OCP\IDBConnection; use OCP\IUser; +use PHPUnit\Framework\MockObject\MockObject; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; use Sabre\DAV\Tree; @@ -40,16 +42,19 @@ */ class CustomPropertiesBackendTest extends TestCase { - /** @var Tree | \PHPUnit\Framework\MockObject\MockObject */ + /** @var Tree | MockObject */ private $tree; /** @var IDBConnection */ private $dbConnection; - /** @var IUser | \PHPUnit\Framework\MockObject\MockObject */ + /** @var CustomPropertiesService */ + private $customPropertiesService; + + /** @var IUser | MockObject */ private $user; - /** @var CustomPropertiesBackend | \PHPUnit\Framework\MockObject\MockObject */ + /** @var CustomPropertiesBackend */ private $backend; protected function setUp(): void { @@ -61,10 +66,12 @@ protected function setUp(): void { ->with() ->willReturn('dummy_user_42'); $this->dbConnection = \OC::$server->getDatabaseConnection(); + $this->customPropertiesService = new CustomPropertiesService($this->dbConnection); $this->backend = new CustomPropertiesBackend( $this->tree, $this->dbConnection, + $this->customPropertiesService, $this->user ); } @@ -122,9 +129,11 @@ protected function getProps(string $user, string $path) { public function testPropFindNoDbCalls() { $db = $this->createMock(IDBConnection::class); + $service = new CustomPropertiesService($db); $backend = new CustomPropertiesBackend( $this->tree, $db, + $service, $this->user ); diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 70a784a5ac1f5..ad4aef38e6433 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -298,6 +298,7 @@ 'OCP\\Files\\Notify\\INotifyHandler' => $baseDir . '/lib/public/Files/Notify/INotifyHandler.php', 'OCP\\Files\\Notify\\IRenameChange' => $baseDir . '/lib/public/Files/Notify/IRenameChange.php', 'OCP\\Files\\ObjectStore\\IObjectStore' => $baseDir . '/lib/public/Files/ObjectStore/IObjectStore.php', + 'OCP\\Files\\ObjectStore\\IObjectStoreMultiPartUpload' => $baseDir . '/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php', 'OCP\\Files\\ReservedWordException' => $baseDir . '/lib/public/Files/ReservedWordException.php', 'OCP\\Files\\Search\\ISearchBinaryOperator' => $baseDir . '/lib/public/Files/Search/ISearchBinaryOperator.php', 'OCP\\Files\\Search\\ISearchComparison' => $baseDir . '/lib/public/Files/Search/ISearchComparison.php', @@ -315,6 +316,7 @@ 'OCP\\Files\\StorageInvalidException' => $baseDir . '/lib/public/Files/StorageInvalidException.php', 'OCP\\Files\\StorageNotAvailableException' => $baseDir . '/lib/public/Files/StorageNotAvailableException.php', 'OCP\\Files\\StorageTimeoutException' => $baseDir . '/lib/public/Files/StorageTimeoutException.php', + 'OCP\\Files\\Storage\\IChunkedFileWrite' => $baseDir . '/lib/public/Files/Storage/IChunkedFileWrite.php', 'OCP\\Files\\Storage\\IDisableEncryptionStorage' => $baseDir . '/lib/public/Files/Storage/IDisableEncryptionStorage.php', 'OCP\\Files\\Storage\\ILockingStorage' => $baseDir . '/lib/public/Files/Storage/ILockingStorage.php', 'OCP\\Files\\Storage\\INotifyStorage' => $baseDir . '/lib/public/Files/Storage/INotifyStorage.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 93a73ef08fdab..69ec0c09971e7 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -327,6 +327,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OCP\\Files\\Notify\\INotifyHandler' => __DIR__ . '/../../..' . '/lib/public/Files/Notify/INotifyHandler.php', 'OCP\\Files\\Notify\\IRenameChange' => __DIR__ . '/../../..' . '/lib/public/Files/Notify/IRenameChange.php', 'OCP\\Files\\ObjectStore\\IObjectStore' => __DIR__ . '/../../..' . '/lib/public/Files/ObjectStore/IObjectStore.php', + 'OCP\\Files\\ObjectStore\\IObjectStoreMultiPartUpload' => __DIR__ . '/../../..' . '/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php', 'OCP\\Files\\ReservedWordException' => __DIR__ . '/../../..' . '/lib/public/Files/ReservedWordException.php', 'OCP\\Files\\Search\\ISearchBinaryOperator' => __DIR__ . '/../../..' . '/lib/public/Files/Search/ISearchBinaryOperator.php', 'OCP\\Files\\Search\\ISearchComparison' => __DIR__ . '/../../..' . '/lib/public/Files/Search/ISearchComparison.php', @@ -344,6 +345,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OCP\\Files\\StorageInvalidException' => __DIR__ . '/../../..' . '/lib/public/Files/StorageInvalidException.php', 'OCP\\Files\\StorageNotAvailableException' => __DIR__ . '/../../..' . '/lib/public/Files/StorageNotAvailableException.php', 'OCP\\Files\\StorageTimeoutException' => __DIR__ . '/../../..' . '/lib/public/Files/StorageTimeoutException.php', + 'OCP\\Files\\Storage\\IChunkedFileWrite' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IChunkedFileWrite.php', 'OCP\\Files\\Storage\\IDisableEncryptionStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IDisableEncryptionStorage.php', 'OCP\\Files\\Storage\\ILockingStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/ILockingStorage.php', 'OCP\\Files\\Storage\\INotifyStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/INotifyStorage.php', diff --git a/lib/private/Files/ObjectStore/ObjectStoreStorage.php b/lib/private/Files/ObjectStore/ObjectStoreStorage.php index 4050daddb35a7..25f5ae099015b 100644 --- a/lib/private/Files/ObjectStore/ObjectStoreStorage.php +++ b/lib/private/Files/ObjectStore/ObjectStoreStorage.php @@ -29,19 +29,27 @@ */ namespace OC\Files\ObjectStore; +use Aws\S3\Exception\S3Exception; +use Aws\S3\Exception\S3MultipartUploadException; use Icewind\Streams\CallbackWrapper; use Icewind\Streams\CountWrapper; use Icewind\Streams\IteratorDirectory; use OC\Files\Cache\Cache; use OC\Files\Cache\CacheEntry; use OC\Files\Storage\PolyFill\CopyDirectory; +use OC\Memcache\ArrayCache; +use OC\Memcache\NullCache; use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; +use OCP\Files\GenericFileException; use OCP\Files\NotFoundException; use OCP\Files\ObjectStore\IObjectStore; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; +use OCP\Files\Storage\IChunkedFileWrite; use OCP\Files\Storage\IStorage; +use OCP\ICache; -class ObjectStoreStorage extends \OC\Files\Storage\Common { +class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFileWrite { use CopyDirectory; /** @@ -61,6 +69,9 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { private $logger; + /** @var ICache */ + private $uploadCache; + public function __construct($params) { if (isset($params['objectstore']) && $params['objectstore'] instanceof IObjectStore) { $this->objectStore = $params['objectstore']; @@ -81,11 +92,11 @@ public function __construct($params) { } $this->logger = \OC::$server->getLogger(); + $this->uploadCache = \OC::$server->getMemCacheFactory()->createDistributed('objectstore'); } public function mkdir($path) { $path = $this->normalizePath($path); - if ($this->file_exists($path)) { return false; } @@ -601,4 +612,104 @@ private function copyFile(ICacheEntry $sourceEntry, string $to) { throw $e; } } + + public function beginChunkedFile(string $targetPath): string { + $this->validateUploadCache(); + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + $uploadId = $this->objectStore->initiateMultipartUpload($urn); + $this->uploadCache->set($this->getUploadCacheKey($urn, $uploadId, 'uploadId'), $uploadId); + return $uploadId; + } + + /** + * + * @throws GenericFileException + */ + public function putChunkedFilePart(string $targetPath, string $writeToken, string $chunkId, $data, $size = null): void { + $this->validateUploadCache(); + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + $uploadId = $this->uploadCache->get($this->getUploadCacheKey($urn, $writeToken, 'uploadId')); + + $result = $this->objectStore->uploadMultipartPart($urn, $uploadId, (int)$chunkId, $data, $size); + + $parts = $this->uploadCache->get($this->getUploadCacheKey($urn, $uploadId, 'parts')); + if (!$parts) { + $parts = []; + } + $parts[$chunkId] = [ + 'PartNumber' => $chunkId, + 'ETag' => trim($result->get('ETag'), '"') + ]; + $this->uploadCache->set($this->getUploadCacheKey($urn, $uploadId, 'parts'), $parts); + } + + public function writeChunkedFile(string $targetPath, string $writeToken): int { + $this->validateUploadCache(); + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + $uploadId = $this->uploadCache->get($this->getUploadCacheKey($urn, $writeToken, 'uploadId')); + $parts = $this->uploadCache->get($this->getUploadCacheKey($urn, $uploadId, 'parts')); + try { + $size = $this->objectStore->completeMultipartUpload($urn, $uploadId, array_values($parts)); + $stat = $this->stat($targetPath); + $mtime = time(); + if (is_array($stat)) { + $stat['size'] = $size; + $stat['mtime'] = $mtime; + $stat['mimetype'] = $this->getMimeType($targetPath); + $this->getCache()->update($stat['fileid'], $stat); + } + } catch (S3MultipartUploadException | S3Exception $e) { + $this->objectStore->abortMultipartUpload($urn, $uploadId); + $this->logger->logException($e, [ + 'app' => 'objectstore', + 'message' => 'Could not compete multipart upload ' . $urn. ' with uploadId ' . $uploadId + ]); + throw new GenericFileException('Could not write chunked file'); + } finally { + $this->clearCache($urn, $uploadId); + } + return $size; + } + + public function cancelChunkedFile(string $targetPath, string $writeToken): void { + $this->validateUploadCache(); + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + $uploadId = $this->uploadCache->get($this->getUploadCacheKey($urn, $writeToken, 'uploadId')); + $this->objectStore->abortMultipartUpload($urn, $uploadId); + $this->clearCache($urn, $uploadId); + } + + /** + * @throws GenericFileException + */ + private function validateUploadCache(): void { + if ($this->uploadCache instanceof NullCache || $this->uploadCache instanceof ArrayCache) { + throw new GenericFileException('ChunkedFileWrite not available: A cross-request persistent cache is required'); + } + } + + private function getUploadCacheKey($urn, $uploadId, $key = null): string { + return $urn . '-' . $uploadId . '-' . ($key ? $key . '-' : ''); + } + + private function clearCache($urn, $uploadId): void { + $this->uploadCache->remove($this->getUploadCacheKey($urn, $uploadId, 'uploadId')); + $this->uploadCache->remove($this->getUploadCacheKey($urn, $uploadId, 'parts')); + } } diff --git a/lib/private/Files/ObjectStore/S3.php b/lib/private/Files/ObjectStore/S3.php index 074f3a1df9182..4056ee3825687 100644 --- a/lib/private/Files/ObjectStore/S3.php +++ b/lib/private/Files/ObjectStore/S3.php @@ -23,9 +23,12 @@ */ namespace OC\Files\ObjectStore; +use Aws\Result; +use Exception; use OCP\Files\ObjectStore\IObjectStore; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; -class S3 implements IObjectStore { +class S3 implements IObjectStore, IObjectStoreMultiPartUpload { use S3ConnectionTrait; use S3ObjectTrait; @@ -40,4 +43,49 @@ public function __construct($parameters) { public function getStorageId() { return $this->id; } + + public function initiateMultipartUpload(string $urn): string { + $upload = $this->getConnection()->createMultipartUpload([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + ] + $this->getSseKmsPutParameters()); + $uploadId = $upload->get('UploadId'); + if ($uploadId === null) { + throw new Exception('No upload id returned'); + } + return (string)$uploadId; + } + + public function uploadMultipartPart(string $urn, string $uploadId, int $partId, $stream, $size): Result { + return $this->getConnection()->uploadPart([ + 'Body' => $stream, + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'ContentLength' => $size, + 'PartNumber' => $partId, + 'UploadId' => $uploadId, + ]); + } + + public function completeMultipartUpload(string $urn, string $uploadId, array $result): int { + $this->getConnection()->completeMultipartUpload([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'UploadId' => $uploadId, + 'MultipartUpload' => ['Parts' => $result], + ] + $this->getSseKmsPutParameters()); + $stat = $this->getConnection()->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + ]); + return (int)$stat->get('ContentLength'); + } + + public function abortMultipartUpload($urn, $uploadId): void { + $this->getConnection()->abortMultipartUpload([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'UploadId' => $uploadId + ]); + } } diff --git a/lib/private/Files/View.php b/lib/private/Files/View.php index 113290e2686fb..0a3802dc7f056 100644 --- a/lib/private/Files/View.php +++ b/lib/private/Files/View.php @@ -61,7 +61,9 @@ use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\Files\ReservedWordException; +use OCP\Files\Storage\IChunkedFileWrite; use OCP\Files\Storage\IStorage; +use OCP\Files\StorageInvalidException; use OCP\ILogger; use OCP\IUser; use OCP\Lock\ILockingProvider; @@ -707,6 +709,22 @@ public function file_put_contents($path, $data) { } } + /** + * @param string $path + * @param string $chunkToken + * @return false|mixed|null + * @throws LockedException + * @throws StorageInvalidException + */ + public function writeChunkedFile(string $path, string $chunkToken) { + /** @var IStorage|null $storage */ + [$storage, ] = Filesystem::resolvePath($path); + if (!$storage || !$storage->instanceOfStorage(IChunkedFileWrite::class)) { + throw new StorageInvalidException('path is not a chunked file write storage'); + } + return $this->basicOperation('writeChunkedFile', $path, ['update', 'write'], $chunkToken); + } + /** * @param string $path * @return bool|mixed diff --git a/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php b/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php new file mode 100644 index 0000000000000..cd94fdc184d31 --- /dev/null +++ b/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php @@ -0,0 +1,54 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +namespace OCP\Files\ObjectStore; + +use Aws\Result; + +/** + * @since 22.0.0 + */ +interface IObjectStoreMultiPartUpload { + /** + * @since 22.0.0 + */ + public function initiateMultipartUpload(string $urn): string; + + /** + * @since 22.0.0 + */ + public function uploadMultipartPart(string $urn, string $uploadId, int $partId, $stream, $size): Result; + + /** + * @since 22.0.0 + */ + public function completeMultipartUpload(string $urn, string $uploadId, array $result): int; + + /** + * @since 22.0.0 + */ + public function abortMultipartUpload(string $urn, string $uploadId): void; +} diff --git a/lib/public/Files/Storage/IChunkedFileWrite.php b/lib/public/Files/Storage/IChunkedFileWrite.php new file mode 100644 index 0000000000000..10eeac2ab4e3a --- /dev/null +++ b/lib/public/Files/Storage/IChunkedFileWrite.php @@ -0,0 +1,71 @@ + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +declare(strict_types=1); + + +namespace OCP\Files\Storage; + +use OCP\Files\GenericFileException; + +/** + * @since 22.0.0 + */ +interface IChunkedFileWrite extends IStorage { + + /** + * @param string $targetPath Relative target path in the storage + * @return string writeToken to be used with the other methods to uniquely identify the file write operation + * @throws GenericFileException + * @since 22.0.0 + */ + public function beginChunkedFile(string $targetPath): string; + + /** + * @param string $targetPath + * @param string $writeToken + * @param string $chunkId + * @param resource $data + * @param int|null $size + * @throws GenericFileException + * @since 22.0.0 + */ + public function putChunkedFilePart(string $targetPath, string $writeToken, string $chunkId, $data, int $size = null): void; + + /** + * @param string $targetPath + * @param string $writeToken + * @return int + * @throws GenericFileException + * @since 22.0.0 + */ + public function writeChunkedFile(string $targetPath, string $writeToken): int; + + /** + * @param string $targetPath + * @param string $writeToken + * @throws GenericFileException + * @since 22.0.0 + */ + public function cancelChunkedFile(string $targetPath, string $writeToken): void; +}