aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/Upload
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/Upload')
-rw-r--r--apps/dav/lib/Upload/AssemblyStream.php289
-rw-r--r--apps/dav/lib/Upload/ChunkingPlugin.php114
-rw-r--r--apps/dav/lib/Upload/ChunkingV2Plugin.php385
-rw-r--r--apps/dav/lib/Upload/CleanupService.php27
-rw-r--r--apps/dav/lib/Upload/FutureFile.php106
-rw-r--r--apps/dav/lib/Upload/PartFile.php91
-rw-r--r--apps/dav/lib/Upload/RootCollection.php50
-rw-r--r--apps/dav/lib/Upload/UploadAutoMkcolPlugin.php68
-rw-r--r--apps/dav/lib/Upload/UploadFile.php75
-rw-r--r--apps/dav/lib/Upload/UploadFolder.php111
-rw-r--r--apps/dav/lib/Upload/UploadHome.php120
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();
+ }
+}