diff options
Diffstat (limited to 'apps/dav/lib/Upload')
-rw-r--r-- | apps/dav/lib/Upload/AssemblyStream.php | 289 | ||||
-rw-r--r-- | apps/dav/lib/Upload/ChunkingPlugin.php | 114 | ||||
-rw-r--r-- | apps/dav/lib/Upload/ChunkingV2Plugin.php | 385 | ||||
-rw-r--r-- | apps/dav/lib/Upload/CleanupService.php | 27 | ||||
-rw-r--r-- | apps/dav/lib/Upload/FutureFile.php | 106 | ||||
-rw-r--r-- | apps/dav/lib/Upload/PartFile.php | 91 | ||||
-rw-r--r-- | apps/dav/lib/Upload/RootCollection.php | 50 | ||||
-rw-r--r-- | apps/dav/lib/Upload/UploadAutoMkcolPlugin.php | 68 | ||||
-rw-r--r-- | apps/dav/lib/Upload/UploadFile.php | 75 | ||||
-rw-r--r-- | apps/dav/lib/Upload/UploadFolder.php | 111 | ||||
-rw-r--r-- | apps/dav/lib/Upload/UploadHome.php | 120 |
11 files changed, 1436 insertions, 0 deletions
diff --git a/apps/dav/lib/Upload/AssemblyStream.php b/apps/dav/lib/Upload/AssemblyStream.php new file mode 100644 index 00000000000..642a8604b17 --- /dev/null +++ b/apps/dav/lib/Upload/AssemblyStream.php @@ -0,0 +1,289 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use Sabre\DAV\IFile; + +/** + * Class AssemblyStream + * + * The assembly stream is a virtual stream that wraps multiple chunks. + * Reading from the stream transparently accessed the underlying chunks and + * give a representation as if they were already merged together. + * + * @package OCA\DAV\Upload + */ +class AssemblyStream implements \Icewind\Streams\File { + + /** @var resource */ + private $context; + + /** @var IFile[] */ + private $nodes; + + /** @var int */ + private $pos = 0; + + /** @var int */ + private $size = 0; + + /** @var resource */ + private $currentStream = null; + + /** @var int */ + private $currentNode = 0; + + /** @var int */ + private $currentNodeRead = 0; + + /** + * @param string $path + * @param string $mode + * @param int $options + * @param string &$opened_path + * @return bool + */ + public function stream_open($path, $mode, $options, &$opened_path) { + $this->loadContext('assembly'); + + $nodes = $this->nodes; + usort($nodes, function (IFile $a, IFile $b) { + return strnatcmp($a->getName(), $b->getName()); + }); + $this->nodes = array_values($nodes); + $this->size = array_reduce($this->nodes, function ($size, IFile $file) { + return $size + $file->getSize(); + }, 0); + + return true; + } + + /** + * @param int $offset + * @param int $whence + * @return bool + */ + public function stream_seek($offset, $whence = SEEK_SET) { + if ($whence === SEEK_CUR) { + $offset = $this->stream_tell() + $offset; + } elseif ($whence === SEEK_END) { + $offset = $this->size + $offset; + } + + if ($offset === $this->pos) { + return true; + } + + if ($offset > $this->size) { + return false; + } + + $nodeIndex = 0; + $nodeStart = 0; + while (true) { + if (!isset($this->nodes[$nodeIndex + 1])) { + break; + } + $node = $this->nodes[$nodeIndex]; + if ($nodeStart + $node->getSize() > $offset) { + break; + } + $nodeIndex++; + $nodeStart += $node->getSize(); + } + + $stream = $this->getStream($this->nodes[$nodeIndex]); + $nodeOffset = $offset - $nodeStart; + if ($nodeOffset > 0 && fseek($stream, $nodeOffset) === -1) { + return false; + } + $this->currentNode = $nodeIndex; + $this->currentNodeRead = $nodeOffset; + $this->currentStream = $stream; + $this->pos = $offset; + + return true; + } + + /** + * @return int + */ + public function stream_tell() { + return $this->pos; + } + + /** + * @param int $count + * @return string + */ + public function stream_read($count) { + if (is_null($this->currentStream)) { + if ($this->currentNode < count($this->nodes)) { + $this->currentStream = $this->getStream($this->nodes[$this->currentNode]); + } else { + return ''; + } + } + + $collectedData = ''; + // read data until we either got all the data requested or there is no more stream left + while ($count > 0 && !is_null($this->currentStream)) { + $data = fread($this->currentStream, $count); + $read = strlen($data); + + $count -= $read; + $collectedData .= $data; + $this->currentNodeRead += $read; + + if (feof($this->currentStream)) { + fclose($this->currentStream); + $currentNodeSize = $this->nodes[$this->currentNode]->getSize(); + if ($this->currentNodeRead < $currentNodeSize) { + throw new \Exception('Stream from assembly node shorter than expected, got ' . $this->currentNodeRead . ' bytes, expected ' . $currentNodeSize); + } + $this->currentNode++; + $this->currentNodeRead = 0; + if ($this->currentNode < count($this->nodes)) { + $this->currentStream = $this->getStream($this->nodes[$this->currentNode]); + } else { + $this->currentStream = null; + } + } + } + + // update position + $this->pos += strlen($collectedData); + return $collectedData; + } + + /** + * @param string $data + * @return int + */ + public function stream_write($data) { + return false; + } + + /** + * @param int $option + * @param int $arg1 + * @param int $arg2 + * @return bool + */ + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + /** + * @param int $size + * @return bool + */ + public function stream_truncate($size) { + return false; + } + + /** + * @return array + */ + public function stream_stat() { + return [ + 'size' => $this->size, + ]; + } + + /** + * @param int $operation + * @return bool + */ + public function stream_lock($operation) { + return false; + } + + /** + * @return bool + */ + public function stream_flush() { + return false; + } + + /** + * @return bool + */ + public function stream_eof() { + return $this->pos >= $this->size || ($this->currentNode >= count($this->nodes) && $this->currentNode === null); + } + + /** + * @return bool + */ + public function stream_close() { + return true; + } + + + /** + * Load the source from the stream context and return the context options + * + * @param string $name + * @return array + * @throws \BadMethodCallException + */ + protected function loadContext($name) { + $context = stream_context_get_options($this->context); + if (isset($context[$name])) { + $context = $context[$name]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); + } + if (isset($context['nodes']) and is_array($context['nodes'])) { + $this->nodes = $context['nodes']; + } else { + throw new \BadMethodCallException('Invalid context, nodes not set'); + } + return $context; + } + + /** + * @param IFile[] $nodes + * @return resource + * + * @throws \BadMethodCallException + */ + public static function wrap(array $nodes) { + $context = stream_context_create([ + 'assembly' => [ + 'nodes' => $nodes + ] + ]); + stream_wrapper_register('assembly', self::class); + try { + $wrapped = fopen('assembly://', 'r', false, $context); + } catch (\BadMethodCallException $e) { + stream_wrapper_unregister('assembly'); + throw $e; + } + stream_wrapper_unregister('assembly'); + return $wrapped; + } + + /** + * @param IFile $node + * @return resource + */ + private function getStream(IFile $node) { + $data = $node->get(); + if (is_resource($data)) { + return $data; + } else { + $tmp = fopen('php://temp', 'w+'); + fwrite($tmp, $data); + rewind($tmp); + return $tmp; + } + } +} diff --git a/apps/dav/lib/Upload/ChunkingPlugin.php b/apps/dav/lib/Upload/ChunkingPlugin.php new file mode 100644 index 00000000000..8cc8f7d6c61 --- /dev/null +++ b/apps/dav/lib/Upload/ChunkingPlugin.php @@ -0,0 +1,114 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2017 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCP\AppFramework\Http; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; + +class ChunkingPlugin extends ServerPlugin { + + /** @var Server */ + private $server; + /** @var FutureFile */ + private $sourceNode; + + /** + * @inheritdoc + */ + public function initialize(Server $server) { + $server->on('beforeMove', [$this, 'beforeMove']); + $this->server = $server; + } + + /** + * @param string $sourcePath source path + * @param string $destination destination path + * @return bool|void + * @throws BadRequest + * @throws NotFound + */ + public function beforeMove($sourcePath, $destination) { + $this->sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if (!$this->sourceNode instanceof FutureFile) { + // skip handling as the source is not a chunked FutureFile + return; + } + + try { + /** @var INode $destinationNode */ + $destinationNode = $this->server->tree->getNodeForPath($destination); + if ($destinationNode instanceof Directory) { + throw new BadRequest("The given destination $destination is a directory."); + } + } catch (NotFound $e) { + // If the destination does not exist yet it's not a directory either ;) + } + + $this->verifySize(); + return $this->performMove($sourcePath, $destination); + } + + /** + * Move handler for future file. + * + * This overrides the default move behavior to prevent Sabre + * to delete the target file before moving. Because deleting would + * lose the file id and metadata. + * + * @param string $path source path + * @param string $destination destination path + * @return bool|void false to stop handling, void to skip this handler + */ + public function performMove($path, $destination) { + $fileExists = $this->server->tree->nodeExists($destination); + // do a move manually, skipping Sabre's default "delete" for existing nodes + try { + $this->server->tree->move($path, $destination); + } catch (Forbidden $e) { + $sourceNode = $this->server->tree->getNodeForPath($path); + if ($sourceNode instanceof FutureFile) { + $sourceNode->delete(); + } + throw $e; + } + + // trigger all default events (copied from CorePlugin::move) + $this->server->emit('afterMove', [$path, $destination]); + $this->server->emit('afterUnbind', [$path]); + $this->server->emit('afterBind', [$destination]); + + $response = $this->server->httpResponse; + $response->setHeader('Content-Length', '0'); + $response->setStatus($fileExists ? Http::STATUS_NO_CONTENT : Http::STATUS_CREATED); + + return false; + } + + /** + * @throws BadRequest + */ + private function verifySize() { + $expectedSize = $this->server->httpRequest->getHeader('OC-Total-Length'); + if ($expectedSize === null) { + return; + } + $actualSize = $this->sourceNode->getSize(); + + // casted to string because cast to float cause equality for non equal numbers + // and integer has the problem of limited size on 32 bit systems + if ((string)$expectedSize !== (string)$actualSize) { + throw new BadRequest("Chunks on server do not sum up to $expectedSize but to $actualSize bytes"); + } + } +} diff --git a/apps/dav/lib/Upload/ChunkingV2Plugin.php b/apps/dav/lib/Upload/ChunkingV2Plugin.php new file mode 100644 index 00000000000..07452dc0593 --- /dev/null +++ b/apps/dav/lib/Upload/ChunkingV2Plugin.php @@ -0,0 +1,385 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Upload; + +use Exception; +use InvalidArgumentException; +use OC\Files\Filesystem; +use OC\Files\ObjectStore\ObjectStoreStorage; +use OC\Files\View; +use OC\Memcache\Memcached; +use OC\Memcache\Redis; +use OC_Hook; +use OCA\DAV\Connector\Sabre\Directory; +use OCA\DAV\Connector\Sabre\File; +use OCP\AppFramework\Http; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; +use OCP\Files\Storage\IChunkedFileWrite; +use OCP\Files\StorageInvalidException; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\Lock\ILockingProvider; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\InsufficientStorage; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\PreconditionFailed; +use Sabre\DAV\ICollection; +use Sabre\DAV\INode; +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; + /** @var ICache */ + private $cache; + + private ?string $uploadId = null; + private ?string $uploadPath = null; + + private const TEMP_TARGET = '.target'; + + public const CACHE_KEY = 'chunking-v2'; + public const UPLOAD_TARGET_PATH = 'upload-target-path'; + public const UPLOAD_TARGET_ID = 'upload-target-id'; + public const UPLOAD_ID = 'upload-id'; + + private const DESTINATION_HEADER = 'Destination'; + + public function __construct(ICacheFactory $cacheFactory) { + $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY); + } + + /** + * @inheritdoc + */ + public function initialize(Server $server) { + $server->on('afterMethod:MKCOL', [$this, 'afterMkcol']); + $server->on('beforeMethod:PUT', [$this, 'beforePut']); + $server->on('beforeMethod:DELETE', [$this, 'beforeDelete']); + $server->on('beforeMove', [$this, 'beforeMove'], 90); + + $this->server = $server; + } + + /** + * @param string $path + * @param bool $createIfNotExists + * @return FutureFile|UploadFile|ICollection|INode + */ + private function getUploadFile(string $path, bool $createIfNotExists = false) { + try { + $actualFile = $this->server->tree->getNodeForPath($path); + // Only directly upload to the target file if it is on the same storage + // There may be further potential to optimize here by also uploading + // to other storages directly. This would require to also carefully pick + // the storage/path used in getStorage() + if ($actualFile instanceof File && $this->uploadFolder->getStorage()->getId() === $actualFile->getNode()->getStorage()->getId()) { + return $actualFile; + } + } catch (NotFound $e) { + // If there is no target file we upload to the upload folder first + } + + // Use file in the upload directory that will be copied or moved afterwards + if ($createIfNotExists) { + $this->uploadFolder->createFile(self::TEMP_TARGET); + } + + /** @var UploadFile $uploadFile */ + $uploadFile = $this->uploadFolder->getChild(self::TEMP_TARGET); + return $uploadFile->getFile(); + } + + public function afterMkcol(RequestInterface $request, ResponseInterface $response): bool { + try { + $this->prepareUpload($request->getPath()); + $this->checkPrerequisites(false); + } catch (BadRequest|StorageInvalidException|NotFound $e) { + return true; + } + + $this->uploadPath = $this->server->calculateUri($this->server->httpRequest->getHeader(self::DESTINATION_HEADER)); + $targetFile = $this->getUploadFile($this->uploadPath, true); + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $this->uploadId = $storage->startChunkedWrite($storagePath); + + $this->cache->set($this->uploadFolder->getName(), [ + self::UPLOAD_ID => $this->uploadId, + self::UPLOAD_TARGET_PATH => $this->uploadPath, + self::UPLOAD_TARGET_ID => $targetFile->getId(), + ], 86400); + + $response->setStatus(Http::STATUS_CREATED); + return true; + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): bool { + try { + $this->prepareUpload(dirname($request->getPath())); + $this->checkPrerequisites(); + } catch (StorageInvalidException|BadRequest|NotFound $e) { + return true; + } + + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $chunkName = basename($request->getPath()); + $partId = is_numeric($chunkName) ? (int)$chunkName : -1; + if (!($partId >= 1 && $partId <= 10000)) { + throw new BadRequest('Invalid chunk name, must be numeric between 1 and 10000'); + } + + $uploadFile = $this->getUploadFile($this->uploadPath); + $tempTargetFile = null; + + $additionalSize = (int)$request->getHeader('Content-Length'); + if ($this->uploadFolder->childExists(self::TEMP_TARGET) && $this->uploadPath) { + /** @var UploadFile $tempTargetFile */ + $tempTargetFile = $this->uploadFolder->getChild(self::TEMP_TARGET); + [$destinationDir, $destinationName] = Uri\split($this->uploadPath); + /** @var Directory $destinationParent */ + $destinationParent = $this->server->tree->getNodeForPath($destinationDir); + $free = $destinationParent->getNode()->getFreeSpace(); + $newSize = $tempTargetFile->getSize() + $additionalSize; + if ($free >= 0 && ($tempTargetFile->getSize() > $free || $newSize > $free)) { + throw new InsufficientStorage("Insufficient space in $this->uploadPath"); + } + } + + $stream = $request->getBodyAsStream(); + $storage->putChunkedWritePart($storagePath, $this->uploadId, (string)$partId, $stream, $additionalSize); + + $storage->getCache()->update($uploadFile->getId(), ['size' => $uploadFile->getSize() + $additionalSize]); + if ($tempTargetFile) { + $storage->getPropagator()->propagateChange($tempTargetFile->getInternalPath(), time(), $additionalSize); + } + + $response->setStatus(201); + return false; + } + + public function beforeMove($sourcePath, $destination): bool { + try { + $this->prepareUpload(dirname($sourcePath)); + $this->checkPrerequisites(); + } catch (StorageInvalidException|BadRequest|NotFound|PreconditionFailed $e) { + return true; + } + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $targetFile = $this->getUploadFile($this->uploadPath); + + [$destinationDir, $destinationName] = Uri\split($destination); + /** @var Directory $destinationParent */ + $destinationParent = $this->server->tree->getNodeForPath($destinationDir); + $destinationExists = $destinationParent->childExists($destinationName); + + + // allow sync clients to send the modification and creation time along in a header + $updateFileInfo = []; + if ($this->server->httpRequest->getHeader('X-OC-MTime') !== null) { + $updateFileInfo['mtime'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-MTime')); + $this->server->httpResponse->setHeader('X-OC-MTime', 'accepted'); + } + if ($this->server->httpRequest->getHeader('X-OC-CTime') !== null) { + $updateFileInfo['creation_time'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-CTime')); + $this->server->httpResponse->setHeader('X-OC-CTime', 'accepted'); + } + $updateFileInfo['mimetype'] = \OCP\Server::get(IMimeTypeDetector::class)->detectPath($destinationName); + + if ($storage->instanceOfStorage(ObjectStoreStorage::class) && $storage->getObjectStore() instanceof IObjectStoreMultiPartUpload) { + /** @var ObjectStoreStorage $storage */ + /** @var IObjectStoreMultiPartUpload $objectStore */ + $objectStore = $storage->getObjectStore(); + $parts = $objectStore->getMultipartUploads($storage->getURN($targetFile->getId()), $this->uploadId); + $size = 0; + foreach ($parts as $part) { + $size += $part['Size']; + } + $free = $destinationParent->getNode()->getFreeSpace(); + if ($free >= 0 && ($size > $free)) { + throw new InsufficientStorage("Insufficient space in $this->uploadPath"); + } + } + + $destinationInView = $destinationParent->getFileInfo()->getPath() . '/' . $destinationName; + $this->completeChunkedWrite($destinationInView); + + $rootView = new View(); + $rootView->putFileInfo($destinationInView, $updateFileInfo); + + $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-Type', 'application/xml; charset=utf-8'); + $response->setHeader('Content-Length', '0'); + $response->setStatus($destinationExists ? Http::STATUS_NO_CONTENT : Http::STATUS_CREATED); + return false; + } + + public function beforeDelete(RequestInterface $request, ResponseInterface $response) { + try { + $this->prepareUpload(dirname($request->getPath())); + $this->checkPrerequisites(); + } catch (StorageInvalidException|BadRequest|NotFound $e) { + return true; + } + + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + $storage->cancelChunkedWrite($storagePath, $this->uploadId); + return true; + } + + /** + * @throws BadRequest + * @throws PreconditionFailed + * @throws StorageInvalidException + */ + private function checkPrerequisites(bool $checkUploadMetadata = true): void { + $distributedCacheConfig = \OCP\Server::get(IConfig::class)->getSystemValue('memcache.distributed', null); + + if ($distributedCacheConfig === null || (!$this->cache instanceof Redis && !$this->cache instanceof Memcached)) { + throw new BadRequest('Skipping chunking v2 since no proper distributed cache is available'); + } + if (!$this->uploadFolder instanceof UploadFolder || empty($this->server->httpRequest->getHeader(self::DESTINATION_HEADER))) { + throw new BadRequest('Skipping chunked file writing as the destination header was not passed'); + } + if (!$this->uploadFolder->getStorage()->instanceOfStorage(IChunkedFileWrite::class)) { + throw new StorageInvalidException('Storage does not support chunked file writing'); + } + if ($this->uploadFolder->getStorage()->instanceOfStorage(ObjectStoreStorage::class) && !$this->uploadFolder->getStorage()->getObjectStore() instanceof IObjectStoreMultiPartUpload) { + throw new StorageInvalidException('Storage does not support multi part uploads'); + } + + if ($checkUploadMetadata) { + if ($this->uploadId === null || $this->uploadPath === null) { + throw new PreconditionFailed('Missing metadata for chunked upload. The distributed cache does not hold the information of previous requests.'); + } + } + } + + /** + * @return array [IStorage, string] + */ + private function getUploadStorage(string $targetPath): array { + $storage = $this->uploadFolder->getStorage(); + $targetFile = $this->getUploadFile($targetPath); + return [$storage, $targetFile->getInternalPath()]; + } + + protected function sanitizeMtime(string $mtimeFromRequest): int { + if (!is_numeric($mtimeFromRequest)) { + throw new InvalidArgumentException('X-OC-MTime header must be an integer (unix timestamp).'); + } + + return (int)$mtimeFromRequest; + } + + /** + * @throws NotFound + */ + public function prepareUpload($path): void { + $this->uploadFolder = $this->server->tree->getNodeForPath($path); + $uploadMetadata = $this->cache->get($this->uploadFolder->getName()); + $this->uploadId = $uploadMetadata[self::UPLOAD_ID] ?? null; + $this->uploadPath = $uploadMetadata[self::UPLOAD_TARGET_PATH] ?? null; + } + + private function completeChunkedWrite(string $targetAbsolutePath): void { + $uploadFile = $this->getUploadFile($this->uploadPath)->getNode(); + [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath); + + $rootFolder = \OCP\Server::get(IRootFolder::class); + $exists = $rootFolder->nodeExists($targetAbsolutePath); + + $uploadFile->lock(ILockingProvider::LOCK_SHARED); + $this->emitPreHooks($targetAbsolutePath, $exists); + try { + $uploadFile->changeLock(ILockingProvider::LOCK_EXCLUSIVE); + $storage->completeChunkedWrite($storagePath, $this->uploadId); + $uploadFile->changeLock(ILockingProvider::LOCK_SHARED); + } catch (Exception $e) { + $uploadFile->unlock(ILockingProvider::LOCK_EXCLUSIVE); + throw $e; + } + + // If the file was not uploaded to the user storage directly we need to copy/move it + try { + $uploadFileAbsolutePath = $uploadFile->getFileInfo()->getPath(); + if ($uploadFileAbsolutePath !== $targetAbsolutePath) { + $uploadFile = $rootFolder->get($uploadFile->getFileInfo()->getPath()); + if ($exists) { + $uploadFile->copy($targetAbsolutePath); + } else { + $uploadFile->move($targetAbsolutePath); + } + } + $this->emitPostHooks($targetAbsolutePath, $exists); + } catch (Exception $e) { + $uploadFile->unlock(ILockingProvider::LOCK_SHARED); + throw $e; + } + } + + private function emitPreHooks(string $target, bool $exists): void { + $hookPath = $this->getHookPath($target); + if (!$exists) { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ + Filesystem::signal_param_path => $hookPath, + ]); + } else { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_write, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + + private function emitPostHooks(string $target, bool $exists): void { + $hookPath = $this->getHookPath($target); + if (!$exists) { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ + Filesystem::signal_param_path => $hookPath, + ]); + } else { + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [ + Filesystem::signal_param_path => $hookPath, + ]); + } + + private function getHookPath(string $path): ?string { + if (!Filesystem::getView()) { + return $path; + } + return Filesystem::getView()->getRelativePath($path); + } +} diff --git a/apps/dav/lib/Upload/CleanupService.php b/apps/dav/lib/Upload/CleanupService.php new file mode 100644 index 00000000000..ffa6bad533c --- /dev/null +++ b/apps/dav/lib/Upload/CleanupService.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Upload; + +use OCA\DAV\BackgroundJob\UploadCleanup; +use OCP\BackgroundJob\IJobList; + +class CleanupService { + public function __construct( + private IJobList $jobList, + ) { + } + + public function addJob(string $uid, string $folder) { + $this->jobList->add(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); + } + + public function removeJob(string $uid, string $folder) { + $this->jobList->remove(UploadCleanup::class, ['uid' => $uid, 'folder' => $folder]); + } +} diff --git a/apps/dav/lib/Upload/FutureFile.php b/apps/dav/lib/Upload/FutureFile.php new file mode 100644 index 00000000000..ba37c56978d --- /dev/null +++ b/apps/dav/lib/Upload/FutureFile.php @@ -0,0 +1,106 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OCA\DAV\Connector\Sabre\Directory; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +/** + * Class FutureFile + * + * The FutureFile is a SabreDav IFile which connects the chunked upload directory + * with the AssemblyStream, who does the final assembly job + * + * @package OCA\DAV\Upload + */ +class FutureFile implements \Sabre\DAV\IFile { + /** + * @param Directory $root + * @param string $name + */ + public function __construct( + private Directory $root, + private $name, + ) { + } + + /** + * @inheritdoc + */ + public function put($data) { + throw new Forbidden('Permission denied to put into this file'); + } + + /** + * @inheritdoc + */ + public function get() { + $nodes = $this->root->getChildren(); + return AssemblyStream::wrap($nodes); + } + + public function getPath() { + return $this->root->getFileInfo()->getInternalPath() . '/.file'; + } + + /** + * @inheritdoc + */ + public function getContentType() { + return 'application/octet-stream'; + } + + /** + * @inheritdoc + */ + public function getETag() { + return $this->root->getETag(); + } + + /** + * @inheritdoc + */ + public function getSize() { + $children = $this->root->getChildren(); + $sizes = array_map(function ($node) { + /** @var IFile $node */ + return $node->getSize(); + }, $children); + + return array_sum($sizes); + } + + /** + * @inheritdoc + */ + public function delete() { + $this->root->delete(); + } + + /** + * @inheritdoc + */ + public function getName() { + return $this->name; + } + + /** + * @inheritdoc + */ + public function setName($name) { + throw new Forbidden('Permission denied to rename this file'); + } + + /** + * @inheritdoc + */ + public function getLastModified() { + return $this->root->getLastModified(); + } +} diff --git a/apps/dav/lib/Upload/PartFile.php b/apps/dav/lib/Upload/PartFile.php new file mode 100644 index 00000000000..11900997a90 --- /dev/null +++ b/apps/dav/lib/Upload/PartFile.php @@ -0,0 +1,91 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OCA\DAV\Connector\Sabre\Directory; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +/** + * This class represents an Upload part which is not present on the storage itself + * but handled directly by external storage services like S3 with Multipart Upload + */ +class PartFile implements IFile { + public function __construct( + private Directory $root, + private array $partInfo, + ) { + } + + /** + * @inheritdoc + */ + public function put($data) { + throw new Forbidden('Permission denied to put into this file'); + } + + /** + * @inheritdoc + */ + public function get() { + throw new Forbidden('Permission denied to get this file'); + } + + public function getPath() { + return $this->root->getFileInfo()->getInternalPath() . '/' . $this->partInfo['PartNumber']; + } + + /** + * @inheritdoc + */ + public function getContentType() { + return 'application/octet-stream'; + } + + /** + * @inheritdoc + */ + public function getETag() { + return $this->partInfo['ETag']; + } + + /** + * @inheritdoc + */ + public function getSize() { + return $this->partInfo['Size']; + } + + /** + * @inheritdoc + */ + public function delete() { + $this->root->delete(); + } + + /** + * @inheritdoc + */ + public function getName() { + return $this->partInfo['PartNumber']; + } + + /** + * @inheritdoc + */ + public function setName($name) { + throw new Forbidden('Permission denied to rename this file'); + } + + /** + * @inheritdoc + */ + public function getLastModified() { + return $this->partInfo['LastModified']; + } +} diff --git a/apps/dav/lib/Upload/RootCollection.php b/apps/dav/lib/Upload/RootCollection.php new file mode 100644 index 00000000000..cd7ab7f5e0a --- /dev/null +++ b/apps/dav/lib/Upload/RootCollection.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OCP\Files\IRootFolder; +use OCP\IUserSession; +use OCP\Share\IManager; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend; + +class RootCollection extends AbstractPrincipalCollection { + + public function __construct( + PrincipalBackend\BackendInterface $principalBackend, + string $principalPrefix, + private CleanupService $cleanupService, + private IRootFolder $rootFolder, + private IUserSession $userSession, + private IManager $shareManager, + ) { + parent::__construct($principalBackend, $principalPrefix); + } + + /** + * @inheritdoc + */ + public function getChildForPrincipal(array $principalInfo): UploadHome { + return new UploadHome( + $principalInfo, + $this->cleanupService, + $this->rootFolder, + $this->userSession, + $this->shareManager, + ); + } + + /** + * @inheritdoc + */ + public function getName(): string { + return 'uploads'; + } +} diff --git a/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php b/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php new file mode 100644 index 00000000000..a7030ba1133 --- /dev/null +++ b/apps/dav/lib/Upload/UploadAutoMkcolPlugin.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Upload; + +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use function Sabre\Uri\split as uriSplit; + +/** + * Class that allows automatically creating non-existing collections on file + * upload. + * + * Since this functionality is not WebDAV compliant, it needs a special + * header to be activated. + */ +class UploadAutoMkcolPlugin extends ServerPlugin { + + private Server $server; + + public function initialize(Server $server): void { + $server->on('beforeMethod:PUT', [$this, 'beforeMethod']); + $this->server = $server; + } + + /** + * @throws NotFound a node expected to exist cannot be found + */ + public function beforeMethod(RequestInterface $request, ResponseInterface $response): bool { + if ($request->getHeader('X-NC-WebDAV-Auto-Mkcol') !== '1') { + return true; + } + + [$path,] = uriSplit($request->getPath()); + + if ($this->server->tree->nodeExists($path)) { + return true; + } + + $parts = explode('/', trim($path, '/')); + $rootPath = array_shift($parts); + $node = $this->server->tree->getNodeForPath('/' . $rootPath); + + if (!($node instanceof ICollection)) { + // the root node is not a collection, let SabreDAV handle it + return true; + } + + foreach ($parts as $part) { + if (!$node->childExists($part)) { + $node->createDirectory($part); + } + + $node = $node->getChild($part); + } + + return true; + } +} diff --git a/apps/dav/lib/Upload/UploadFile.php b/apps/dav/lib/Upload/UploadFile.php new file mode 100644 index 00000000000..7301e855cfe --- /dev/null +++ b/apps/dav/lib/Upload/UploadFile.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Upload; + +use OCA\DAV\Connector\Sabre\File; +use Sabre\DAV\IFile; + +class UploadFile implements IFile { + public function __construct( + private File $file, + ) { + } + + public function put($data) { + return $this->file->put($data); + } + + public function get() { + return $this->file->get(); + } + + public function getId() { + return $this->file->getId(); + } + + public function getContentType() { + return $this->file->getContentType(); + } + + public function getETag() { + return $this->file->getETag(); + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float + */ + public function getSize() { + return $this->file->getSize(); + } + + public function delete() { + $this->file->delete(); + } + + public function getName() { + return $this->file->getName(); + } + + public function setName($name) { + $this->file->setName($name); + } + + public function getLastModified() { + return $this->file->getLastModified(); + } + + public function getInternalPath(): string { + return $this->file->getInternalPath(); + } + + public function getFile(): File { + return $this->file; + } + + public function getNode() { + return $this->file->getNode(); + } +} diff --git a/apps/dav/lib/Upload/UploadFolder.php b/apps/dav/lib/Upload/UploadFolder.php new file mode 100644 index 00000000000..8890d472f87 --- /dev/null +++ b/apps/dav/lib/Upload/UploadFolder.php @@ -0,0 +1,111 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OC\Files\ObjectStore\ObjectStoreStorage; +use OCA\DAV\Connector\Sabre\Directory; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; +use OCP\Files\Storage\IStorage; +use OCP\ICacheFactory; +use OCP\Server; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; + +class UploadFolder implements ICollection { + public function __construct( + private Directory $node, + private CleanupService $cleanupService, + private IStorage $storage, + private string $uid, + ) { + } + + public function createFile($name, $data = null) { + // TODO: verify name - should be a simple number + try { + $this->node->createFile($name, $data); + } catch (\Exception $e) { + if ($this->node->childExists($name)) { + $child = $this->node->getChild($name); + $child->delete(); + } + throw $e; + } + } + + public function createDirectory($name) { + throw new Forbidden('Permission denied to create file (filename ' . $name . ')'); + } + + public function getChild($name) { + if ($name === '.file') { + return new FutureFile($this->node, '.file'); + } + return new UploadFile($this->node->getChild($name)); + } + + public function getChildren() { + $tmpChildren = $this->node->getChildren(); + + $children = []; + $children[] = new FutureFile($this->node, '.file'); + + foreach ($tmpChildren as $child) { + $children[] = new UploadFile($child); + } + + if ($this->storage->instanceOfStorage(ObjectStoreStorage::class)) { + /** @var ObjectStoreStorage $storage */ + $objectStore = $this->storage->getObjectStore(); + if ($objectStore instanceof IObjectStoreMultiPartUpload) { + $cache = Server::get(ICacheFactory::class)->createDistributed(ChunkingV2Plugin::CACHE_KEY); + $uploadSession = $cache->get($this->getName()); + if ($uploadSession) { + $uploadId = $uploadSession[ChunkingV2Plugin::UPLOAD_ID]; + $id = $uploadSession[ChunkingV2Plugin::UPLOAD_TARGET_ID]; + $parts = $objectStore->getMultipartUploads($this->storage->getURN($id), $uploadId); + foreach ($parts as $part) { + $children[] = new PartFile($this->node, $part); + } + } + } + } + + return $children; + } + + public function childExists($name) { + if ($name === '.file') { + return true; + } + return $this->node->childExists($name); + } + + public function delete() { + $this->node->delete(); + + // Background cleanup job is not needed anymore + $this->cleanupService->removeJob($this->uid, $this->getName()); + } + + public function getName() { + return $this->node->getName(); + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + 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 new file mode 100644 index 00000000000..4042f1c4101 --- /dev/null +++ b/apps/dav/lib/Upload/UploadHome.php @@ -0,0 +1,120 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Upload; + +use OC\Files\View; +use OCA\DAV\Connector\Sabre\Directory; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IUserSession; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; + +class UploadHome implements ICollection { + private string $uid; + private ?Folder $uploadFolder = null; + + public function __construct( + private readonly array $principalInfo, + private readonly CleanupService $cleanupService, + private readonly IRootFolder $rootFolder, + private readonly IUserSession $userSession, + private readonly \OCP\Share\IManager $shareManager, + ) { + [$prefix, $name] = \Sabre\Uri\split($principalInfo['uri']); + if ($prefix === 'principals/shares') { + $this->uid = $this->shareManager->getShareByToken($name)->getShareOwner(); + } else { + $user = $this->userSession->getUser(); + if (!$user) { + throw new Forbidden('Not logged in'); + } + + $this->uid = $user->getUID(); + } + } + + public function createFile($name, $data = null) { + throw new Forbidden('Permission denied to create file (filename ' . $name . ')'); + } + + public function createDirectory($name) { + $this->impl()->createDirectory($name); + + // Add a cleanup job + $this->cleanupService->addJob($this->uid, $name); + } + + public function getChild($name): UploadFolder { + return new UploadFolder( + $this->impl()->getChild($name), + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); + } + + public function getChildren(): array { + return array_map(function ($node) { + return new UploadFolder( + $node, + $this->cleanupService, + $this->getStorage(), + $this->uid, + ); + }, $this->impl()->getChildren()); + } + + public function childExists($name): bool { + return !is_null($this->getChild($name)); + } + + public function delete() { + $this->impl()->delete(); + } + + public function getName() { + [,$name] = \Sabre\Uri\split($this->principalInfo['uri']); + return $name; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this folder'); + } + + public function getLastModified() { + return $this->impl()->getLastModified(); + } + + private function getUploadFolder(): Folder { + if ($this->uploadFolder === null) { + $path = '/' . $this->uid . '/uploads'; + try { + $folder = $this->rootFolder->get($path); + if (!$folder instanceof Folder) { + throw new \Exception('Upload folder is a file'); + } + $this->uploadFolder = $folder; + } catch (NotFoundException $e) { + $this->uploadFolder = $this->rootFolder->newFolder($path); + } + } + return $this->uploadFolder; + } + + private function impl(): Directory { + $folder = $this->getUploadFolder(); + $view = new View($folder->getPath()); + return new Directory($view, $folder); + } + + private function getStorage() { + return $this->getUploadFolder()->getStorage(); + } +} |