Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@
'OCA\\DAV\\Upload\\FutureFile' => $baseDir . '/../lib/Upload/FutureFile.php',
'OCA\\DAV\\Upload\\PartFile' => $baseDir . '/../lib/Upload/PartFile.php',
'OCA\\DAV\\Upload\\RootCollection' => $baseDir . '/../lib/Upload/RootCollection.php',
'OCA\\DAV\\Upload\\UploadAutoMkcolPlugin' => $baseDir . '/../lib/Upload/UploadAutoMkcolPlugin.php',
'OCA\\DAV\\Upload\\UploadFile' => $baseDir . '/../lib/Upload/UploadFile.php',
'OCA\\DAV\\Upload\\UploadFolder' => $baseDir . '/../lib/Upload/UploadFolder.php',
'OCA\\DAV\\Upload\\UploadHome' => $baseDir . '/../lib/Upload/UploadHome.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Upload\\FutureFile' => __DIR__ . '/..' . '/../lib/Upload/FutureFile.php',
'OCA\\DAV\\Upload\\PartFile' => __DIR__ . '/..' . '/../lib/Upload/PartFile.php',
'OCA\\DAV\\Upload\\RootCollection' => __DIR__ . '/..' . '/../lib/Upload/RootCollection.php',
'OCA\\DAV\\Upload\\UploadAutoMkcolPlugin' => __DIR__ . '/..' . '/../lib/Upload/UploadAutoMkcolPlugin.php',
'OCA\\DAV\\Upload\\UploadFile' => __DIR__ . '/..' . '/../lib/Upload/UploadFile.php',
'OCA\\DAV\\Upload\\UploadFolder' => __DIR__ . '/..' . '/../lib/Upload/UploadFolder.php',
'OCA\\DAV\\Upload\\UploadHome' => __DIR__ . '/..' . '/../lib/Upload/UploadHome.php',
Expand Down
2 changes: 2 additions & 0 deletions apps/dav/lib/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
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;
Expand Down Expand Up @@ -232,6 +233,7 @@ public function __construct(

$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(
Expand Down
68 changes: 68 additions & 0 deletions apps/dav/lib/Upload/UploadAutoMkcolPlugin.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
135 changes: 135 additions & 0 deletions apps/dav/tests/unit/Upload/UploadAutoMkcolPluginTest.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading