Skip to content

Commit 08b58a2

Browse files
authored
Merge pull request #53621 from nextcloud/feature/53428-autoCreateCollectionOnUpload
Feature/53428 auto create collection on upload
2 parents d00519d + 3cb28d5 commit 08b58a2

File tree

5 files changed

+207
-0
lines changed

5 files changed

+207
-0
lines changed

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@
395395
'OCA\\DAV\\Upload\\FutureFile' => $baseDir . '/../lib/Upload/FutureFile.php',
396396
'OCA\\DAV\\Upload\\PartFile' => $baseDir . '/../lib/Upload/PartFile.php',
397397
'OCA\\DAV\\Upload\\RootCollection' => $baseDir . '/../lib/Upload/RootCollection.php',
398+
'OCA\\DAV\\Upload\\UploadAutoMkcolPlugin' => $baseDir . '/../lib/Upload/UploadAutoMkcolPlugin.php',
398399
'OCA\\DAV\\Upload\\UploadFile' => $baseDir . '/../lib/Upload/UploadFile.php',
399400
'OCA\\DAV\\Upload\\UploadFolder' => $baseDir . '/../lib/Upload/UploadFolder.php',
400401
'OCA\\DAV\\Upload\\UploadHome' => $baseDir . '/../lib/Upload/UploadHome.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ class ComposerStaticInitDAV
410410
'OCA\\DAV\\Upload\\FutureFile' => __DIR__ . '/..' . '/../lib/Upload/FutureFile.php',
411411
'OCA\\DAV\\Upload\\PartFile' => __DIR__ . '/..' . '/../lib/Upload/PartFile.php',
412412
'OCA\\DAV\\Upload\\RootCollection' => __DIR__ . '/..' . '/../lib/Upload/RootCollection.php',
413+
'OCA\\DAV\\Upload\\UploadAutoMkcolPlugin' => __DIR__ . '/..' . '/../lib/Upload/UploadAutoMkcolPlugin.php',
413414
'OCA\\DAV\\Upload\\UploadFile' => __DIR__ . '/..' . '/../lib/Upload/UploadFile.php',
414415
'OCA\\DAV\\Upload\\UploadFolder' => __DIR__ . '/..' . '/../lib/Upload/UploadFolder.php',
415416
'OCA\\DAV\\Upload\\UploadHome' => __DIR__ . '/..' . '/../lib/Upload/UploadHome.php',

apps/dav/lib/Server.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
use OCA\DAV\SystemTag\SystemTagPlugin;
6464
use OCA\DAV\Upload\ChunkingPlugin;
6565
use OCA\DAV\Upload\ChunkingV2Plugin;
66+
use OCA\DAV\Upload\UploadAutoMkcolPlugin;
6667
use OCA\Theming\ThemingDefaults;
6768
use OCP\Accounts\IAccountManager;
6869
use OCP\App\IAppManager;
@@ -232,6 +233,7 @@ public function __construct(
232233

233234
$this->server->addPlugin(new CopyEtagHeaderPlugin());
234235
$this->server->addPlugin(new RequestIdHeaderPlugin(\OCP\Server::get(IRequest::class)));
236+
$this->server->addPlugin(new UploadAutoMkcolPlugin());
235237
$this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class)));
236238
$this->server->addPlugin(new ChunkingPlugin());
237239
$this->server->addPlugin(new ZipFolderPlugin(
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\DAV\Upload;
10+
11+
use Sabre\DAV\Exception\NotFound;
12+
use Sabre\DAV\ICollection;
13+
use Sabre\DAV\Server;
14+
use Sabre\DAV\ServerPlugin;
15+
use Sabre\HTTP\RequestInterface;
16+
use Sabre\HTTP\ResponseInterface;
17+
use function Sabre\Uri\split as uriSplit;
18+
19+
/**
20+
* Class that allows automatically creating non-existing collections on file
21+
* upload.
22+
*
23+
* Since this functionality is not WebDAV compliant, it needs a special
24+
* header to be activated.
25+
*/
26+
class UploadAutoMkcolPlugin extends ServerPlugin {
27+
28+
private Server $server;
29+
30+
public function initialize(Server $server): void {
31+
$server->on('beforeMethod:PUT', [$this, 'beforeMethod']);
32+
$this->server = $server;
33+
}
34+
35+
/**
36+
* @throws NotFound a node expected to exist cannot be found
37+
*/
38+
public function beforeMethod(RequestInterface $request, ResponseInterface $response): bool {
39+
if ($request->getHeader('X-NC-WebDAV-Auto-Mkcol') !== '1') {
40+
return true;
41+
}
42+
43+
[$path,] = uriSplit($request->getPath());
44+
45+
if ($this->server->tree->nodeExists($path)) {
46+
return true;
47+
}
48+
49+
$parts = explode('/', trim($path, '/'));
50+
$rootPath = array_shift($parts);
51+
$node = $this->server->tree->getNodeForPath('/' . $rootPath);
52+
53+
if (!($node instanceof ICollection)) {
54+
// the root node is not a collection, let SabreDAV handle it
55+
return true;
56+
}
57+
58+
foreach ($parts as $part) {
59+
if (!$node->childExists($part)) {
60+
$node->createDirectory($part);
61+
}
62+
63+
$node = $node->getChild($part);
64+
}
65+
66+
return true;
67+
}
68+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
namespace OCA\DAV\Tests\unit\Upload;
10+
11+
use Generator;
12+
use OCA\DAV\Upload\UploadAutoMkcolPlugin;
13+
use PHPUnit\Framework\MockObject\MockObject;
14+
use Sabre\DAV\ICollection;
15+
use Sabre\DAV\INode;
16+
use Sabre\DAV\Server;
17+
use Sabre\DAV\Tree;
18+
use Sabre\HTTP\RequestInterface;
19+
use Sabre\HTTP\ResponseInterface;
20+
use Test\TestCase;
21+
22+
class UploadAutoMkcolPluginTest extends TestCase {
23+
24+
private Tree&MockObject $tree;
25+
private RequestInterface&MockObject $request;
26+
private ResponseInterface&MockObject $response;
27+
28+
public static function dataMissingHeaderShouldReturnTrue(): Generator {
29+
yield 'missing X-NC-WebDAV-Auto-Mkcol header' => [null];
30+
yield 'empty X-NC-WebDAV-Auto-Mkcol header' => [''];
31+
yield 'invalid X-NC-WebDAV-Auto-Mkcol header' => ['enable'];
32+
}
33+
34+
public function testBeforeMethodWithRootNodeNotAnICollectionShouldReturnTrue(): void {
35+
$this->request->method('getHeader')->willReturn('1');
36+
$this->request->expects(self::once())
37+
->method('getPath')
38+
->willReturn('/non-relevant/path.txt');
39+
$this->tree->expects(self::once())
40+
->method('nodeExists')
41+
->with('/non-relevant')
42+
->willReturn(false);
43+
44+
$mockNode = $this->getMockBuilder(INode::class);
45+
$this->tree->expects(self::once())
46+
->method('getNodeForPath')
47+
->willReturn($mockNode);
48+
49+
$return = $this->plugin->beforeMethod($this->request, $this->response);
50+
$this->assertTrue($return);
51+
}
52+
53+
/**
54+
* @dataProvider dataMissingHeaderShouldReturnTrue
55+
*/
56+
public function testBeforeMethodWithMissingHeaderShouldReturnTrue(?string $header): void {
57+
$this->request->expects(self::once())
58+
->method('getHeader')
59+
->with('X-NC-WebDAV-Auto-Mkcol')
60+
->willReturn($header);
61+
62+
$this->request->expects(self::never())
63+
->method('getPath');
64+
65+
$return = $this->plugin->beforeMethod($this->request, $this->response);
66+
self::assertTrue($return);
67+
}
68+
69+
public function testBeforeMethodWithExistingPathShouldReturnTrue(): void {
70+
$this->request->method('getHeader')->willReturn('1');
71+
$this->request->expects(self::once())
72+
->method('getPath')
73+
->willReturn('/files/user/deep/image.jpg');
74+
$this->tree->expects(self::once())
75+
->method('nodeExists')
76+
->with('/files/user/deep')
77+
->willReturn(true);
78+
79+
$this->tree->expects(self::never())
80+
->method('getNodeForPath');
81+
82+
$return = $this->plugin->beforeMethod($this->request, $this->response);
83+
self::assertTrue($return);
84+
}
85+
86+
public function testBeforeMethodShouldSucceed(): void {
87+
$this->request->method('getHeader')->willReturn('1');
88+
$this->request->expects(self::once())
89+
->method('getPath')
90+
->willReturn('/files/user/my/deep/path/image.jpg');
91+
$this->tree->expects(self::once())
92+
->method('nodeExists')
93+
->with('/files/user/my/deep/path')
94+
->willReturn(false);
95+
96+
$mockNode = $this->createMock(ICollection::class);
97+
$this->tree->expects(self::once())
98+
->method('getNodeForPath')
99+
->with('/files')
100+
->willReturn($mockNode);
101+
$mockNode->expects(self::exactly(4))
102+
->method('childExists')
103+
->willReturnMap([
104+
['user', true],
105+
['my', true],
106+
['deep', false],
107+
['path', false],
108+
]);
109+
$mockNode->expects(self::exactly(2))
110+
->method('createDirectory');
111+
$mockNode->expects(self::exactly(4))
112+
->method('getChild')
113+
->willReturn($mockNode);
114+
115+
$return = $this->plugin->beforeMethod($this->request, $this->response);
116+
self::assertTrue($return);
117+
}
118+
119+
protected function setUp(): void {
120+
parent::setUp();
121+
122+
$server = $this->createMock(Server::class);
123+
$this->tree = $this->createMock(Tree::class);
124+
125+
$server->tree = $this->tree;
126+
$this->plugin = new UploadAutoMkcolPlugin();
127+
128+
$this->request = $this->createMock(RequestInterface::class);
129+
$this->response = $this->createMock(ResponseInterface::class);
130+
$server->httpRequest = $this->request;
131+
$server->httpResponse = $this->response;
132+
133+
$this->plugin->initialize($server);
134+
}
135+
}

0 commit comments

Comments
 (0)