feat(files): automatically create directories on upload

Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
pull/53621/head
Salvatore Martire 2025-06-19 14:41:08 +07:00
parent d00519d7ec
commit 8167b07118
3 changed files with 205 additions and 0 deletions

@ -63,6 +63,7 @@ use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin;
use OCA\DAV\SystemTag\SystemTagPlugin;
use OCA\DAV\Upload\ChunkingPlugin;
use OCA\DAV\Upload\ChunkingV2Plugin;
use OCA\DAV\Upload\UploadAutoMkcolPlugin;
use OCA\Theming\ThemingDefaults;
use OCP\Accounts\IAccountManager;
use OCP\App\IAppManager;
@ -232,6 +233,7 @@ class Server {
$this->server->addPlugin(new CopyEtagHeaderPlugin());
$this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class)));
$this->server->addPlugin(new UploadAutoMkcolPlugin());
$this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class)));
$this->server->addPlugin(new ChunkingPlugin());
$this->server->addPlugin(new ZipFolderPlugin(

@ -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;
}
}

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\Upload;
use Generator;
use OCA\DAV\Upload\UploadAutoMkcolPlugin;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\DAV\ICollection;
use Sabre\DAV\INode;
use Sabre\DAV\Server;
use Sabre\DAV\Tree;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Test\TestCase;
class UploadAutoMkcolPluginTest extends TestCase {
private Tree&MockObject $tree;
private RequestInterface&MockObject $request;
private ResponseInterface&MockObject $response;
public static function dataMissingHeaderShouldReturnTrue(): Generator {
yield 'missing X-NC-WebDAV-Auto-Mkcol header' => [null];
yield 'empty X-NC-WebDAV-Auto-Mkcol header' => [''];
yield 'invalid X-NC-WebDAV-Auto-Mkcol header' => ['enable'];
}
public function testBeforeMethodWithRootNodeNotAnICollectionShouldReturnTrue(): void {
$this->request->method('getHeader')->willReturn('1');
$this->request->expects(self::once())
->method('getPath')
->willReturn('/non-relevant/path.txt');
$this->tree->expects(self::once())
->method('nodeExists')
->with('/non-relevant')
->willReturn(false);
$mockNode = $this->getMockBuilder(INode::class);
$this->tree->expects(self::once())
->method('getNodeForPath')
->willReturn($mockNode);
$return = $this->plugin->beforeMethod($this->request, $this->response);
$this->assertTrue($return);
}
/**
* @dataProvider dataMissingHeaderShouldReturnTrue
*/
public function testBeforeMethodWithMissingHeaderShouldReturnTrue(?string $header): void {
$this->request->expects(self::once())
->method('getHeader')
->with('X-NC-WebDAV-Auto-Mkcol')
->willReturn($header);
$this->request->expects(self::never())
->method('getPath');
$return = $this->plugin->beforeMethod($this->request, $this->response);
self::assertTrue($return);
}
public function testBeforeMethodWithExistingPathShouldReturnTrue(): void {
$this->request->method('getHeader')->willReturn('1');
$this->request->expects(self::once())
->method('getPath')
->willReturn('/files/user/deep/image.jpg');
$this->tree->expects(self::once())
->method('nodeExists')
->with('/files/user/deep')
->willReturn(true);
$this->tree->expects(self::never())
->method('getNodeForPath');
$return = $this->plugin->beforeMethod($this->request, $this->response);
self::assertTrue($return);
}
public function testBeforeMethodShouldSucceed(): void {
$this->request->method('getHeader')->willReturn('1');
$this->request->expects(self::once())
->method('getPath')
->willReturn('/files/user/my/deep/path/image.jpg');
$this->tree->expects(self::once())
->method('nodeExists')
->with('/files/user/my/deep/path')
->willReturn(false);
$mockNode = $this->createMock(ICollection::class);
$this->tree->expects(self::once())
->method('getNodeForPath')
->with('/files')
->willReturn($mockNode);
$mockNode->expects(self::exactly(4))
->method('childExists')
->willReturnMap([
['user', true],
['my', true],
['deep', false],
['path', false],
]);
$mockNode->expects(self::exactly(2))
->method('createDirectory');
$mockNode->expects(self::exactly(4))
->method('getChild')
->willReturn($mockNode);
$return = $this->plugin->beforeMethod($this->request, $this->response);
self::assertTrue($return);
}
protected function setUp(): void {
parent::setUp();
$server = $this->createMock(Server::class);
$this->tree = $this->createMock(Tree::class);
$server->tree = $this->tree;
$this->plugin = new UploadAutoMkcolPlugin();
$this->request = $this->createMock(RequestInterface::class);
$this->response = $this->createMock(ResponseInterface::class);
$server->httpRequest = $this->request;
$server->httpResponse = $this->response;
$this->plugin->initialize($server);
}
}