Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f6a9168

Browse files
committedFeb 13, 2024
Merge #247 [backport25] S3 Multipart turbo upload
2 parents f05121c + c684599 commit f6a9168

33 files changed

+1069
-31
lines changed
 

‎apps/dav/composer/composer/autoload_classmap.php

+3
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@
297297
'OCA\\DAV\\Search\\EventsSearchProvider' => $baseDir . '/../lib/Search/EventsSearchProvider.php',
298298
'OCA\\DAV\\Search\\TasksSearchProvider' => $baseDir . '/../lib/Search/TasksSearchProvider.php',
299299
'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php',
300+
'OCA\\DAV\\Service\\CustomPropertiesService' => $baseDir . '/../lib/Service/CustomPropertiesService.php',
300301
'OCA\\DAV\\Settings\\AvailabilitySettings' => $baseDir . '/../lib/Settings/AvailabilitySettings.php',
301302
'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php',
302303
'OCA\\DAV\\Storage\\PublicOwnerWrapper' => $baseDir . '/../lib/Storage/PublicOwnerWrapper.php',
@@ -311,8 +312,10 @@
311312
'OCA\\DAV\\Traits\\PrincipalProxyTrait' => $baseDir . '/../lib/Traits/PrincipalProxyTrait.php',
312313
'OCA\\DAV\\Upload\\AssemblyStream' => $baseDir . '/../lib/Upload/AssemblyStream.php',
313314
'OCA\\DAV\\Upload\\ChunkingPlugin' => $baseDir . '/../lib/Upload/ChunkingPlugin.php',
315+
'OCA\\DAV\\Upload\\ChunkingV2Plugin' => $baseDir . '/../lib/Upload/ChunkingV2Plugin.php',
314316
'OCA\\DAV\\Upload\\CleanupService' => $baseDir . '/../lib/Upload/CleanupService.php',
315317
'OCA\\DAV\\Upload\\FutureFile' => $baseDir . '/../lib/Upload/FutureFile.php',
318+
'OCA\\DAV\\Upload\\PartFile' => $baseDir . '/../lib/Upload/PartFile.php',
316319
'OCA\\DAV\\Upload\\RootCollection' => $baseDir . '/../lib/Upload/RootCollection.php',
317320
'OCA\\DAV\\Upload\\UploadFile' => $baseDir . '/../lib/Upload/UploadFile.php',
318321
'OCA\\DAV\\Upload\\UploadFolder' => $baseDir . '/../lib/Upload/UploadFolder.php',

‎apps/dav/composer/composer/autoload_static.php

+3
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ class ComposerStaticInitDAV
312312
'OCA\\DAV\\Search\\EventsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/EventsSearchProvider.php',
313313
'OCA\\DAV\\Search\\TasksSearchProvider' => __DIR__ . '/..' . '/../lib/Search/TasksSearchProvider.php',
314314
'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php',
315+
'OCA\\DAV\\Service\\CustomPropertiesService' => __DIR__ . '/..' . '/../lib/Service/CustomPropertiesService.php',
315316
'OCA\\DAV\\Settings\\AvailabilitySettings' => __DIR__ . '/..' . '/../lib/Settings/AvailabilitySettings.php',
316317
'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php',
317318
'OCA\\DAV\\Storage\\PublicOwnerWrapper' => __DIR__ . '/..' . '/../lib/Storage/PublicOwnerWrapper.php',
@@ -326,8 +327,10 @@ class ComposerStaticInitDAV
326327
'OCA\\DAV\\Traits\\PrincipalProxyTrait' => __DIR__ . '/..' . '/../lib/Traits/PrincipalProxyTrait.php',
327328
'OCA\\DAV\\Upload\\AssemblyStream' => __DIR__ . '/..' . '/../lib/Upload/AssemblyStream.php',
328329
'OCA\\DAV\\Upload\\ChunkingPlugin' => __DIR__ . '/..' . '/../lib/Upload/ChunkingPlugin.php',
330+
'OCA\\DAV\\Upload\\ChunkingV2Plugin' => __DIR__ . '/..' . '/../lib/Upload/ChunkingV2Plugin.php',
329331
'OCA\\DAV\\Upload\\CleanupService' => __DIR__ . '/..' . '/../lib/Upload/CleanupService.php',
330332
'OCA\\DAV\\Upload\\FutureFile' => __DIR__ . '/..' . '/../lib/Upload/FutureFile.php',
333+
'OCA\\DAV\\Upload\\PartFile' => __DIR__ . '/..' . '/../lib/Upload/PartFile.php',
331334
'OCA\\DAV\\Upload\\RootCollection' => __DIR__ . '/..' . '/../lib/Upload/RootCollection.php',
332335
'OCA\\DAV\\Upload\\UploadFile' => __DIR__ . '/..' . '/../lib/Upload/UploadFile.php',
333336
'OCA\\DAV\\Upload\\UploadFolder' => __DIR__ . '/..' . '/../lib/Upload/UploadFolder.php',

‎apps/dav/lib/BackgroundJob/UploadCleanup.php

+9-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
namespace OCA\DAV\BackgroundJob;
2929

3030
use OC\User\NoUserException;
31+
use OCA\DAV\Service\CustomPropertiesService;
3132
use OCP\AppFramework\Utility\ITimeFactory;
3233
use OCP\BackgroundJob\IJob;
3334
use OCP\BackgroundJob\IJobList;
@@ -42,12 +43,14 @@
4243
class UploadCleanup extends TimedJob {
4344
private IRootFolder $rootFolder;
4445
private IJobList $jobList;
46+
private CustomPropertiesService $customPropertiesService;
4547
private LoggerInterface $logger;
4648

47-
public function __construct(ITimeFactory $time, IRootFolder $rootFolder, IJobList $jobList, LoggerInterface $logger) {
49+
public function __construct(ITimeFactory $time, IRootFolder $rootFolder, IJobList $jobList, CustomPropertiesService $customPropertiesService, LoggerInterface $logger) {
4850
parent::__construct($time);
4951
$this->rootFolder = $rootFolder;
5052
$this->jobList = $jobList;
53+
$this->customPropertiesService = $customPropertiesService;
5154
$this->logger = $logger;
5255

5356
// Run once a day
@@ -70,6 +73,10 @@ protected function run($argument) {
7073
return;
7174
}
7275

76+
$files = $uploadFolder->getDirectoryListing();
77+
78+
$davPath = 'uploads/' . $uid . '/' . $uploadFolder->getName();
79+
7380
// Remove if all files have an mtime of more than a day
7481
$time = $this->time->getTime() - 60 * 60 * 24;
7582

@@ -93,6 +100,7 @@ protected function run($argument) {
93100
}, $initial);
94101

95102
if ($expire) {
103+
$this->customPropertiesService->delete($uid, $davPath);
96104
$uploadFolder->delete();
97105
$this->jobList->remove(self::class, $argument);
98106
}

‎apps/dav/lib/Capabilities.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function __construct(IConfig $config) {
3636
public function getCapabilities() {
3737
$capabilities = [
3838
'dav' => [
39-
'chunking' => '1.0',
39+
'chunking' => '2.0'
4040
]
4141
];
4242
if ($this->config->getSystemValueBool('bulkupload.enabled', true)) {

‎apps/dav/lib/Connector/Sabre/Directory.php

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
use OCA\DAV\Connector\Sabre\Exception\FileLocked;
4040
use OCA\DAV\Connector\Sabre\Exception\Forbidden;
4141
use OCA\DAV\Connector\Sabre\Exception\InvalidPath;
42+
use OCA\DAV\Upload\FutureFile;
4243
use OCP\Files\FileInfo;
4344
use OCP\Files\Folder;
4445
use OCP\Files\ForbiddenException;

‎apps/dav/lib/Connector/Sabre/Node.php

+8
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,14 @@ public function getInternalFileId() {
269269
return $this->info->getId();
270270
}
271271

272+
public function getInternalPath(): string {
273+
return $this->info->getInternalPath();
274+
}
275+
276+
public function getAbsoluteInternalPath(): string {
277+
return $this->info->getPath();
278+
}
279+
272280
/**
273281
* @param string $user
274282
* @return int

‎apps/dav/lib/Connector/Sabre/ServerFactory.php

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use OCA\DAV\AppInfo\PluginManager;
3636
use OCA\DAV\DAV\ViewOnlyPlugin;
3737
use OCA\DAV\Files\BrowserErrorPagePlugin;
38+
use OCA\DAV\Service\CustomPropertiesService;
3839
use OCP\Files\Mount\IMountManager;
3940
use OCP\IConfig;
4041
use OCP\IDBConnection;
@@ -190,6 +191,7 @@ public function createServer(string $baseUri,
190191
new \OCA\DAV\DAV\CustomPropertiesBackend(
191192
$objectTree,
192193
$this->databaseConnection,
194+
\OC::$server->get(CustomPropertiesService::class),
193195
$this->userSession->getUser()
194196
)
195197
)

‎apps/dav/lib/DAV/CustomPropertiesBackend.php

+9-6
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Exception;
2929
use OCA\DAV\Connector\Sabre\Directory;
3030
use OCP\DB\QueryBuilder\IQueryBuilder;
31+
use OCA\DAV\Service\CustomPropertiesService;
3132
use OCP\IDBConnection;
3233
use OCP\IUser;
3334
use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
@@ -116,6 +117,11 @@ class CustomPropertiesBackend implements BackendInterface {
116117
*/
117118
private $connection;
118119

120+
/**
121+
* @var CustomPropertiesService
122+
*/
123+
private $customPropertiesService;
124+
119125
/**
120126
* @var IUser
121127
*/
@@ -136,10 +142,12 @@ class CustomPropertiesBackend implements BackendInterface {
136142
public function __construct(
137143
Tree $tree,
138144
IDBConnection $connection,
145+
CustomPropertiesService $customPropertiesService,
139146
IUser $user
140147
) {
141148
$this->tree = $tree;
142149
$this->connection = $connection;
150+
$this->customPropertiesService = $customPropertiesService;
143151
$this->user = $user;
144152
}
145153

@@ -218,12 +226,7 @@ public function propPatch($path, PropPatch $propPatch) {
218226
* @param string $path path of node for which to delete properties
219227
*/
220228
public function delete($path) {
221-
$statement = $this->connection->prepare(
222-
'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
223-
);
224-
$statement->execute([$this->user->getUID(), $this->formatPath($path)]);
225-
$statement->closeCursor();
226-
229+
$this->customPropertiesService->delete($this->user->getUID(), $path);
227230
unset($this->userCache[$path]);
228231
}
229232

‎apps/dav/lib/Server.php

+4
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,12 @@
6868
use OCA\DAV\Files\LazySearchBackend;
6969
use OCA\DAV\Profiler\ProfilerPlugin;
7070
use OCA\DAV\Provisioning\Apple\AppleProvisioningPlugin;
71+
use OCA\DAV\Service\CustomPropertiesService;
7172
use OCA\DAV\SystemTag\SystemTagPlugin;
7273
use OCA\DAV\Upload\ChunkingPlugin;
7374
use OCP\AppFramework\Http\Response;
7475
use OCP\Diagnostics\IEventLogger;
76+
use OCA\DAV\Upload\ChunkingV2Plugin;
7577
use OCP\EventDispatcher\IEventDispatcher;
7678
use OCP\IRequest;
7779
use OCP\Profiler\IProfiler;
@@ -215,6 +217,7 @@ public function __construct(IRequest $request, string $baseUri) {
215217

216218
$this->server->addPlugin(new CopyEtagHeaderPlugin());
217219
$this->server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class)));
220+
$this->server->addPlugin(new ChunkingV2Plugin());
218221
$this->server->addPlugin(new ChunkingPlugin());
219222

220223
// allow setup of additional plugins
@@ -267,6 +270,7 @@ public function __construct(IRequest $request, string $baseUri) {
267270
new CustomPropertiesBackend(
268271
$this->server->tree,
269272
\OC::$server->getDatabaseConnection(),
273+
\OC::$server->get(CustomPropertiesService::class),
270274
\OC::$server->getUserSession()->getUser()
271275
)
272276
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
/*
3+
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
4+
*
5+
* @author Julius Härtl <jus@bitgrid.net>
6+
*
7+
* @license GNU AGPL version 3 or any later version
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as
11+
* published by the Free Software Foundation, either version 3 of the
12+
* License, or (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*
22+
*/
23+
24+
declare(strict_types=1);
25+
26+
27+
namespace OCA\DAV\Service;
28+
29+
use OCP\IDBConnection;
30+
31+
class CustomPropertiesService {
32+
33+
/** @var IDBConnection */
34+
private $connection;
35+
36+
public function __construct(IDBConnection $connection) {
37+
$this->connection = $connection;
38+
}
39+
40+
public function delete(string $userId, string $path): void {
41+
$statement = $this->connection->prepare(
42+
'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
43+
);
44+
$result = $statement->execute([$userId, $this->formatPath($path)]);
45+
$result->closeCursor();
46+
}
47+
48+
/**
49+
* long paths are hashed to ensure they fit in the database
50+
*
51+
* @param string $path
52+
* @return string
53+
*/
54+
private function formatPath(string $path): string {
55+
if (strlen($path) > 250) {
56+
return sha1($path);
57+
}
58+
return $path;
59+
}
60+
}
+340
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/*
5+
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
6+
*
7+
* @author Julius Härtl <jus@bitgrid.net>
8+
*
9+
* @license GNU AGPL version 3 or any later version
10+
*
11+
* This program is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU Affero General Public License as
13+
* published by the Free Software Foundation, either version 3 of the
14+
* License, or (at your option) any later version.
15+
*
16+
* This program is distributed in the hope that it will be useful,
17+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
* GNU Affero General Public License for more details.
20+
*
21+
* You should have received a copy of the GNU Affero General Public License
22+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
23+
*
24+
*/
25+
26+
namespace OCA\DAV\Upload;
27+
28+
use OC\Files\ObjectStore\ObjectStoreStorage;
29+
use OC\Files\View;
30+
use OCA\DAV\Connector\Sabre\Directory;
31+
use OCA\DAV\Connector\Sabre\FilesPlugin;
32+
use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload;
33+
use OCP\Files\Storage\IChunkedFileWrite;
34+
use OCP\Files\Storage\IProcessingCallbackStorage;
35+
use OCP\Files\Storage\IStorage;
36+
use OCP\Files\StorageInvalidException;
37+
use Sabre\DAV\Exception\BadRequest;
38+
use Sabre\DAV\Exception\InsufficientStorage;
39+
use Sabre\DAV\Exception\NotFound;
40+
use Sabre\DAV\Exception\PreconditionFailed;
41+
use Sabre\DAV\Server;
42+
use Sabre\DAV\ServerPlugin;
43+
use Sabre\DAV\Xml\Element\Response;
44+
use Sabre\DAV\Xml\Response\MultiStatus;
45+
use Sabre\HTTP\RequestInterface;
46+
use Sabre\HTTP\ResponseInterface;
47+
use Sabre\Uri;
48+
49+
class ChunkingV2Plugin extends ServerPlugin {
50+
51+
/** @var Server */
52+
private $server;
53+
/** @var UploadFolder */
54+
private $uploadFolder;
55+
56+
private const TEMP_TARGET = '.target';
57+
58+
public const OBJECT_UPLOAD_CACHE_KEY = 'chunkedv2';
59+
public const OBJECT_UPLOAD_TARGET_ID = '{http://nextcloud.org/ns}upload-target-id';
60+
public const OBJECT_UPLOAD_TARGET = '{http://nextcloud.org/ns}upload-target';
61+
public const OBJECT_UPLOAD_CHUNKTOKEN = '{http://nextcloud.org/ns}upload-chunktoken';
62+
63+
private const DESTINATION_HEADER = 'X-Chunking-Destination';
64+
65+
/**
66+
* @inheritdoc
67+
*/
68+
public function initialize(Server $server) {
69+
$server->on('afterMethod:MKCOL', [$this, 'beforeMkcol']);
70+
// 200 priority to call after the custom properties backend is registered
71+
$server->on('beforeMethod:PUT', [$this, 'beforePut'], 200);
72+
$server->on('beforeMethod:DELETE', [$this, 'beforeDelete'], 200);
73+
$server->on('beforeMove', [$this, 'beforeMove'], 90);
74+
75+
$this->server = $server;
76+
77+
$this->cache = \OC::$server->getMemCacheFactory()->createDistributed(self::OBJECT_UPLOAD_CACHE_KEY);
78+
}
79+
80+
/**
81+
* @param string $path
82+
* @param bool $createIfNotExists
83+
* @return FutureFile|UploadFile|\Sabre\DAV\ICollection|\Sabre\DAV\INode
84+
*/
85+
private function getTargetFile(string $path, bool $createIfNotExists = false) {
86+
try {
87+
$targetFile = $this->server->tree->getNodeForPath($path);
88+
} catch (NotFound $e) {
89+
if ($createIfNotExists) {
90+
$this->uploadFolder->createFile(self::TEMP_TARGET);
91+
}
92+
$targetFile = $this->uploadFolder->getChild(self::TEMP_TARGET);
93+
}
94+
return $targetFile;
95+
}
96+
97+
public function beforeMkcol(RequestInterface $request, ResponseInterface $response): bool {
98+
$this->uploadFolder = $this->server->tree->getNodeForPath($request->getPath());
99+
try {
100+
$this->checkPrerequisites();
101+
$storage = $this->getStorage();
102+
} catch (StorageInvalidException | BadRequest $e) {
103+
return true;
104+
}
105+
106+
$targetPath = $this->server->httpRequest->getHeader(self::DESTINATION_HEADER);
107+
if (!$targetPath) {
108+
return true;
109+
}
110+
111+
$targetPath = $this->server->calculateUri($targetPath);
112+
113+
$targetFile = $this->getTargetFile($targetPath, true);
114+
115+
$uploadId = $storage->beginChunkedFile($targetFile->getInternalPath());
116+
117+
$this->cache->set($this->uploadFolder->getName(), [
118+
self::OBJECT_UPLOAD_TARGET_ID => $targetFile->getId(),
119+
self::OBJECT_UPLOAD_CHUNKTOKEN => $uploadId,
120+
self::OBJECT_UPLOAD_TARGET => $targetPath,
121+
]);
122+
123+
$response->setStatus(201);
124+
return true;
125+
}
126+
127+
public function beforePut(RequestInterface $request, ResponseInterface $response): bool {
128+
$this->uploadFolder = $this->server->tree->getNodeForPath(dirname($request->getPath()));
129+
if (!$this->uploadFolder instanceof UploadFolder) {
130+
return true;
131+
}
132+
133+
try {
134+
$this->checkPrerequisites();
135+
$storage = $this->getStorage();
136+
} catch (StorageInvalidException | BadRequest $e) {
137+
return true;
138+
}
139+
140+
$properties = $this->getUploadSession();
141+
$targetPath = $properties[self::OBJECT_UPLOAD_TARGET] ?? null;
142+
$uploadId = $properties[self::OBJECT_UPLOAD_CHUNKTOKEN] ?? null;
143+
if (empty($targetPath) || empty($uploadId)) {
144+
throw new PreconditionFailed('Missing metadata for chunked upload');
145+
}
146+
$partId = (int)basename($request->getPath());
147+
148+
if (!($partId >= 1 && $partId <= 10000)) {
149+
throw new BadRequest('Invalid chunk id');
150+
}
151+
152+
$targetFile = $this->getTargetFile($targetPath);
153+
$cacheEntry = $storage->getCache()->get($targetFile->getInternalPath());
154+
$tempTargetFile = null;
155+
156+
$additionalSize = (int)$request->getHeader('Content-Length');
157+
if ($this->uploadFolder->childExists(self::TEMP_TARGET)) {
158+
/** @var UploadFile $tempTargetFile */
159+
$tempTargetFile = $this->uploadFolder->getChild(self::TEMP_TARGET);
160+
$tempTargetCache = $storage->getCache()->get($tempTargetFile->getInternalPath());
161+
162+
[$destinationDir, $destinationName] = Uri\split($targetPath);
163+
/** @var Directory $destinationParent */
164+
$destinationParent = $this->server->tree->getNodeForPath($destinationDir);
165+
$free = $storage->free_space($destinationParent->getInternalPath());
166+
$newSize = $tempTargetCache->getSize() + $additionalSize;
167+
if ($free >= 0 && ($tempTargetCache->getSize() > $free || $newSize > $free)) {
168+
throw new InsufficientStorage("Insufficient space in $targetPath");
169+
}
170+
}
171+
172+
$stream = $request->getBodyAsStream();
173+
$storage->putChunkedFilePart($targetFile->getInternalPath(), $uploadId, (string)$partId, $stream, $additionalSize);
174+
175+
$storage->getCache()->update($cacheEntry->getId(), ['size' => $cacheEntry->getSize() + $additionalSize]);
176+
if ($tempTargetFile) {
177+
$storage->getPropagator()->propagateChange($tempTargetFile->getInternalPath(), time(), $additionalSize);
178+
}
179+
180+
$response->setStatus(201);
181+
return false;
182+
}
183+
184+
public function beforeMove($sourcePath, $destination): bool {
185+
$this->uploadFolder = $this->server->tree->getNodeForPath(dirname($sourcePath));
186+
try {
187+
$this->checkPrerequisites();
188+
$storage = $this->getStorage();
189+
} catch (StorageInvalidException | BadRequest $e) {
190+
return true;
191+
}
192+
$properties = $this->getUploadSession();
193+
$targetPath = $properties[self::OBJECT_UPLOAD_TARGET] ?? null;
194+
$uploadId = $properties[self::OBJECT_UPLOAD_CHUNKTOKEN] ?? null;
195+
196+
// FIXME: check if $destination === TARGET
197+
if (empty($targetPath) || empty($uploadId)) {
198+
throw new PreconditionFailed('Missing metadata for chunked upload');
199+
}
200+
201+
$targetFile = $this->getTargetFile($targetPath);
202+
203+
[$destinationDir, $destinationName] = Uri\split($destination);
204+
/** @var Directory $destinationParent */
205+
$destinationParent = $this->server->tree->getNodeForPath($destinationDir);
206+
$destinationExists = $destinationParent->childExists($destinationName);
207+
208+
// Using a multipart status here in order to be able to sent the actual status after processing the move
209+
$this->server->httpResponse->setStatus(207);
210+
$this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
211+
212+
// allow sync clients to send the modification and creation time along in a header
213+
$updateFileInfo = [];
214+
if ($this->server->httpRequest->getHeader('X-OC-MTime') !== null) {
215+
$updateFileInfo['mtime'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-MTime'));
216+
$this->server->httpResponse->setHeader('X-OC-MTime', 'accepted');
217+
}
218+
if ($this->server->httpRequest->getHeader('X-OC-CTime') !== null) {
219+
$updateFileInfo['creation_time'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-CTime'));
220+
$this->server->httpResponse->setHeader('X-OC-CTime', 'accepted');
221+
}
222+
223+
$rootView = new View();
224+
if ($storage->instanceOfStorage(ObjectStoreStorage::class)) {
225+
/** @var ObjectStoreStorage $storage */
226+
$objectStore = $storage->getObjectStore();
227+
if ($objectStore instanceof IObjectStoreMultiPartUpload) {
228+
$parts = $objectStore->getMultipartUploads($storage->getURN($targetFile->getId()), $uploadId);
229+
$size = 0;
230+
foreach ($parts as $part) {
231+
$size += $part['Size'];
232+
}
233+
$free = $storage->free_space($destinationParent->getInternalPath());
234+
if ($free >= 0 && ($size > $free)) {
235+
throw new InsufficientStorage("Insufficient space in $targetPath");
236+
}
237+
}
238+
}
239+
if ($storage->instanceOfStorage(IProcessingCallbackStorage::class)) {
240+
/** @var IProcessingCallbackStorage $storage */
241+
$lastTick = time();
242+
$storage->processingCallback('writeChunkedFile', function () use ($lastTick) {
243+
if ($lastTick < time()) {
244+
\OC_Util::obEnd();
245+
echo ' ';
246+
flush();
247+
}
248+
$lastTick = time();
249+
});
250+
}
251+
252+
$this->server->httpResponse->setBody(function () use ($targetFile, $rootView, $uploadId, $destinationName, $destinationParent, $destinationExists, $sourcePath, $destination, $updateFileInfo) {
253+
$rootView->writeChunkedFile($targetFile->getAbsoluteInternalPath(), $uploadId);
254+
$destinationInView = $destinationParent->getFileInfo()->getPath() . '/' . $destinationName;
255+
if (!$destinationExists) {
256+
$rootView->rename($targetFile->getAbsoluteInternalPath(), $destinationInView);
257+
}
258+
259+
$rootView->putFileInfo($destinationInView, $updateFileInfo);
260+
261+
$sourceNode = $this->server->tree->getNodeForPath($sourcePath);
262+
if ($sourceNode instanceof FutureFile) {
263+
$this->uploadFolder->delete();
264+
}
265+
266+
$this->server->emit('afterMove', [$sourcePath, $destination]);
267+
$this->server->emit('afterUnbind', [$sourcePath]);
268+
$this->server->emit('afterBind', [$destination]);
269+
270+
$response = new Response(
271+
$destination,
272+
['200' => [
273+
FilesPlugin::SIZE_PROPERTYNAME => $rootView->filesize($destinationInView)
274+
]],
275+
$destinationExists ? '204' : '201'
276+
);
277+
echo $this->server->xml->write(
278+
'{DAV:}multistatus',
279+
new MultiStatus([$response])
280+
);
281+
});
282+
return false;
283+
}
284+
285+
public function beforeDelete(RequestInterface $request, ResponseInterface $response) {
286+
$this->uploadFolder = $this->server->tree->getNodeForPath($request->getPath());
287+
try {
288+
if (!$this->uploadFolder instanceof UploadFolder) {
289+
return true;
290+
}
291+
$storage = $this->getStorage();
292+
} catch (StorageInvalidException | BadRequest $e) {
293+
return true;
294+
}
295+
296+
$properties = $this->getUploadSession();
297+
$targetPath = $properties[self::OBJECT_UPLOAD_TARGET];
298+
$uploadId = $properties[self::OBJECT_UPLOAD_CHUNKTOKEN];
299+
if (!$targetPath || !$uploadId) {
300+
return true;
301+
}
302+
$targetFile = $this->getTargetFile($targetPath);
303+
$storage->cancelChunkedFile($targetFile->getInternalPath(), $uploadId);
304+
return true;
305+
}
306+
307+
/** @throws BadRequest */
308+
private function checkPrerequisites(): void {
309+
if (!$this->uploadFolder instanceof UploadFolder || !$this->server->httpRequest->getHeader(self::DESTINATION_HEADER)) {
310+
throw new BadRequest('Chunking destination header not set');
311+
}
312+
}
313+
314+
/**
315+
* @return IChunkedFileWrite
316+
* @throws BadRequest
317+
* @throws StorageInvalidException
318+
*/
319+
private function getStorage(): IStorage {
320+
$this->checkPrerequisites();
321+
$storage = $this->uploadFolder->getStorage();
322+
if (!$storage->instanceOfStorage(IChunkedFileWrite::class)) {
323+
throw new StorageInvalidException('Storage does not support chunked file write');
324+
}
325+
/** @var IChunkedFileWrite $storage */
326+
return $storage;
327+
}
328+
329+
protected function sanitizeMtime($mtimeFromRequest) {
330+
if (!is_numeric($mtimeFromRequest)) {
331+
throw new \InvalidArgumentException('X-OC-MTime header must be an integer (unix timestamp).');
332+
}
333+
334+
return (int)$mtimeFromRequest;
335+
}
336+
337+
public function getUploadSession(): array {
338+
return $this->cache->get($this->uploadFolder->getName()) ?? [];
339+
}
340+
}

‎apps/dav/lib/Upload/FutureFile.php

+4
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ public function get() {
6666
return AssemblyStream::wrap($nodes);
6767
}
6868

69+
public function getPath() {
70+
return $this->root->getFileInfo()->getInternalPath() . '/.file';
71+
}
72+
6973
/**
7074
* @inheritdoc
7175
*/

‎apps/dav/lib/Upload/PartFile.php

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
/**
3+
* @copyright Copyright (c) 2016, ownCloud, Inc.
4+
*
5+
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
6+
* @author Lukas Reschke <lukas@statuscode.ch>
7+
* @author Thomas Müller <thomas.mueller@tmit.eu>
8+
*
9+
* @license AGPL-3.0
10+
*
11+
* This code is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU Affero General Public License, version 3,
13+
* as published by the Free Software Foundation.
14+
*
15+
* This program is distributed in the hope that it will be useful,
16+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
* GNU Affero General Public License for more details.
19+
*
20+
* You should have received a copy of the GNU Affero General Public License, version 3,
21+
* along with this program. If not, see <http://www.gnu.org/licenses/>
22+
*
23+
*/
24+
namespace OCA\DAV\Upload;
25+
26+
use OCA\DAV\Connector\Sabre\Directory;
27+
use Sabre\DAV\Exception\Forbidden;
28+
use Sabre\DAV\IFile;
29+
30+
/**
31+
* This class represents an Upload part which is not present on the storage itself
32+
* but handled directly by external storage services like S3 with Multipart Upload
33+
*/
34+
class PartFile implements IFile {
35+
36+
/** @var Directory */
37+
private $root;
38+
/** @var array */
39+
private $partInfo;
40+
41+
public function __construct(Directory $root, array $partInfo) {
42+
$this->root = $root;
43+
$this->partInfo = $partInfo;
44+
}
45+
46+
/**
47+
* @inheritdoc
48+
*/
49+
public function put($data) {
50+
throw new Forbidden('Permission denied to put into this file');
51+
}
52+
53+
/**
54+
* @inheritdoc
55+
*/
56+
public function get() {
57+
throw new Forbidden('Permission denied to get this file');
58+
}
59+
60+
public function getPath() {
61+
return $this->root->getFileInfo()->getInternalPath() . '/' . $this->partInfo['PartNumber'];
62+
}
63+
64+
/**
65+
* @inheritdoc
66+
*/
67+
public function getContentType() {
68+
return 'application/octet-stream';
69+
}
70+
71+
/**
72+
* @inheritdoc
73+
*/
74+
public function getETag() {
75+
return $this->partInfo['ETag'];
76+
}
77+
78+
/**
79+
* @inheritdoc
80+
*/
81+
public function getSize() {
82+
return $this->partInfo['Size'];
83+
}
84+
85+
/**
86+
* @inheritdoc
87+
*/
88+
public function delete() {
89+
$this->root->delete();
90+
}
91+
92+
/**
93+
* @inheritdoc
94+
*/
95+
public function getName() {
96+
return $this->partInfo['PartNumber'];
97+
}
98+
99+
/**
100+
* @inheritdoc
101+
*/
102+
public function setName($name) {
103+
throw new Forbidden('Permission denied to rename this file');
104+
}
105+
106+
/**
107+
* @inheritdoc
108+
*/
109+
public function getLastModified() {
110+
return $this->partInfo['LastModified'];
111+
}
112+
}

‎apps/dav/lib/Upload/UploadFile.php

+12
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ public function get() {
4545
return $this->file->get();
4646
}
4747

48+
public function getId() {
49+
return $this->file->getId();
50+
}
51+
4852
public function getContentType() {
4953
return $this->file->getContentType();
5054
}
@@ -72,4 +76,12 @@ public function setName($name) {
7276
public function getLastModified() {
7377
return $this->file->getLastModified();
7478
}
79+
80+
public function getInternalPath(): string {
81+
return $this->file->getInternalPath();
82+
}
83+
84+
public function getAbsoluteInternalPath(): string {
85+
return $this->file->getFileInfo()->getPath();
86+
}
7587
}

‎apps/dav/lib/Upload/UploadFolder.php

+28-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@
2424
*/
2525
namespace OCA\DAV\Upload;
2626

27+
use OC\Files\ObjectStore\ObjectStoreStorage;
2728
use OCA\DAV\Connector\Sabre\Directory;
29+
use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload;
30+
use OCP\Files\Storage\IStorage;
2831
use Sabre\DAV\Exception\Forbidden;
2932
use Sabre\DAV\ICollection;
3033

@@ -34,10 +37,13 @@ class UploadFolder implements ICollection {
3437
private $node;
3538
/** @var CleanupService */
3639
private $cleanupService;
40+
/** @var IStorage */
41+
private $storage;
3742

38-
public function __construct(Directory $node, CleanupService $cleanupService) {
43+
public function __construct(Directory $node, CleanupService $cleanupService, IStorage $storage) {
3944
$this->node = $node;
4045
$this->cleanupService = $cleanupService;
46+
$this->storage = $storage;
4147
}
4248

4349
public function createFile($name, $data = null) {
@@ -66,6 +72,23 @@ public function getChildren() {
6672
$children[] = new UploadFile($child);
6773
}
6874

75+
if ($this->storage->instanceOfStorage(ObjectStoreStorage::class)) {
76+
/** @var ObjectStoreStorage $storage */
77+
$objectStore = $this->storage->getObjectStore();
78+
if ($objectStore instanceof IObjectStoreMultiPartUpload) {
79+
$cache = \OC::$server->getMemCacheFactory()->createDistributed(ChunkingV2Plugin::OBJECT_UPLOAD_CACHE_KEY);
80+
$uploadSession = $cache->get($this->getName());
81+
if ($uploadSession) {
82+
$uploadId = $uploadSession[ChunkingV2Plugin::OBJECT_UPLOAD_CHUNKTOKEN];
83+
$id = $uploadSession[ChunkingV2Plugin::OBJECT_UPLOAD_TARGET_ID];
84+
$parts = $objectStore->getMultipartUploads($this->storage->getURN($id), $uploadId);
85+
foreach ($parts as $part) {
86+
$children[] = new PartFile($this->node, $part);
87+
}
88+
}
89+
}
90+
}
91+
6992
return $children;
7093
}
7194

@@ -94,4 +117,8 @@ public function setName($name) {
94117
public function getLastModified() {
95118
return $this->node->getLastModified();
96119
}
120+
121+
public function getStorage() {
122+
return $this->storage;
123+
}
97124
}

‎apps/dav/lib/Upload/UploadHome.php

+15-5
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ public function createDirectory($name) {
5555
}
5656

5757
public function getChild($name): UploadFolder {
58-
return new UploadFolder($this->impl()->getChild($name), $this->cleanupService);
58+
return new UploadFolder($this->impl()->getChild($name), $this->cleanupService, $this->getStorage());
5959
}
6060

6161
public function getChildren(): array {
6262
return array_map(function ($node) {
63-
return new UploadFolder($node, $this->cleanupService);
63+
return new UploadFolder($node, $this->cleanupService, $this->getStorage());
6464
}, $this->impl()->getChildren());
6565
}
6666

@@ -89,14 +89,24 @@ public function getLastModified() {
8989
* @return Directory
9090
*/
9191
private function impl() {
92+
$view = $this->getView();
93+
$rootInfo = $view->getFileInfo('');
94+
return new Directory($view, $rootInfo);
95+
}
96+
97+
private function getView() {
9298
$rootView = new View();
9399
$user = \OC::$server->getUserSession()->getUser();
94100
Filesystem::initMountPoints($user->getUID());
95101
if (!$rootView->file_exists('/' . $user->getUID() . '/uploads')) {
96102
$rootView->mkdir('/' . $user->getUID() . '/uploads');
97103
}
98-
$view = new View('/' . $user->getUID() . '/uploads');
99-
$rootInfo = $view->getFileInfo('');
100-
return new Directory($view, $rootInfo);
104+
return new View('/' . $user->getUID() . '/uploads');
105+
}
106+
107+
private function getStorage() {
108+
$view = $this->getView();
109+
$storage = $view->getFileInfo('')->getStorage();
110+
return $storage;
101111
}
102112
}

‎apps/dav/tests/unit/CapabilitiesTest.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ public function testGetCapabilities() {
4040
$capabilities = new Capabilities($config);
4141
$expected = [
4242
'dav' => [
43-
'chunking' => '1.0',
43+
'chunking' => '2.0',
4444
],
4545
];
4646
$this->assertSame($expected, $capabilities->getCapabilities());

‎apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
use OCA\DAV\Connector\Sabre\Directory;
4040
use OCA\DAV\Connector\Sabre\File;
41+
use OCA\DAV\Service\CustomPropertiesService;
4142
use OCP\IUser;
4243
use Sabre\DAV\Tree;
4344

@@ -89,6 +90,7 @@ protected function setUp(): void {
8990
$this->plugin = new \OCA\DAV\DAV\CustomPropertiesBackend(
9091
$this->tree,
9192
\OC::$server->getDatabaseConnection(),
93+
$this->createMock(CustomPropertiesService::class),
9294
$this->user
9395
);
9496
}

‎apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php

+12-3
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
namespace OCA\DAV\Tests\DAV;
2929

3030
use OCA\DAV\DAV\CustomPropertiesBackend;
31+
use OCA\DAV\Service\CustomPropertiesService;
3132
use OCP\IDBConnection;
3233
use OCP\IUser;
34+
use PHPUnit\Framework\MockObject\MockObject;
3335
use Sabre\DAV\PropFind;
3436
use Sabre\DAV\PropPatch;
3537
use Sabre\DAV\Tree;
@@ -40,16 +42,19 @@
4042
*/
4143
class CustomPropertiesBackendTest extends TestCase {
4244

43-
/** @var Tree | \PHPUnit\Framework\MockObject\MockObject */
45+
/** @var Tree | MockObject */
4446
private $tree;
4547

4648
/** @var IDBConnection */
4749
private $dbConnection;
4850

49-
/** @var IUser | \PHPUnit\Framework\MockObject\MockObject */
51+
/** @var CustomPropertiesService */
52+
private $customPropertiesService;
53+
54+
/** @var IUser | MockObject */
5055
private $user;
5156

52-
/** @var CustomPropertiesBackend | \PHPUnit\Framework\MockObject\MockObject */
57+
/** @var CustomPropertiesBackend */
5358
private $backend;
5459

5560
protected function setUp(): void {
@@ -61,10 +66,12 @@ protected function setUp(): void {
6166
->with()
6267
->willReturn('dummy_user_42');
6368
$this->dbConnection = \OC::$server->getDatabaseConnection();
69+
$this->customPropertiesService = new CustomPropertiesService($this->dbConnection);
6470

6571
$this->backend = new CustomPropertiesBackend(
6672
$this->tree,
6773
$this->dbConnection,
74+
$this->customPropertiesService,
6875
$this->user
6976
);
7077
}
@@ -122,9 +129,11 @@ protected function getProps(string $user, string $path) {
122129

123130
public function testPropFindNoDbCalls() {
124131
$db = $this->createMock(IDBConnection::class);
132+
$service = new CustomPropertiesService($db);
125133
$backend = new CustomPropertiesBackend(
126134
$this->tree,
127135
$db,
136+
$service,
128137
$this->user
129138
);
130139

‎apps/files/js/file-upload.js

+23-3
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,15 @@ OC.FileUpload.prototype = {
269269
&& this.getFile().size > this.uploader.fileUploadParam.maxChunkSize
270270
) {
271271
data.isChunked = true;
272+
var headers = {};
273+
if (OC.getCapabilities().dav.chunking === '2.0') {
274+
headers = {
275+
'X-Chunking-Destination': this.uploader.davClient._buildUrl(this.getTargetDestination())
276+
};
277+
}
278+
272279
chunkFolderPromise = this.uploader.davClient.createDirectory(
273-
'uploads/' + OC.getCurrentUser().uid + '/' + this.getId()
280+
'uploads/' + OC.getCurrentUser().uid + '/' + this.getId(), headers
274281
);
275282
// TODO: if fails, it means same id already existed, need to retry
276283
} else {
@@ -309,17 +316,24 @@ OC.FileUpload.prototype = {
309316
}
310317
if (size) {
311318
headers['OC-Total-Length'] = size;
312-
319+
}
320+
if (OC.getCapabilities().dav.chunking === '2.0') {
321+
headers['X-Chunking-Destination'] = this.uploader.davClient._buildUrl(this.getTargetDestination());
313322
}
314323

315324
return this.uploader.davClient.move(
316325
'uploads/' + uid + '/' + this.getId() + '/.file',
317-
'files/' + uid + '/' + OC.joinPaths(this.getFullPath(), this.getFileName()),
326+
this.getTargetDestination(),
318327
true,
319328
headers
320329
);
321330
},
322331

332+
getTargetDestination: function() {
333+
var uid = OC.getCurrentUser().uid;
334+
return 'files/' + uid + '/' + OC.joinPaths(this.getFullPath(), this.getFileName());
335+
},
336+
323337
_deleteChunkFolder: function() {
324338
// delete transfer directory for this upload
325339
this.uploader.davClient.remove(
@@ -1326,6 +1340,12 @@ OC.Uploader.prototype = _.extend({
13261340
}
13271341
var range = data.contentRange.split(' ')[1];
13281342
var chunkId = range.split('/')[0].split('-')[0];
1343+
if (OC.getCapabilities().dav.chunking === '2.0') {
1344+
// Calculate chunk index for usage with s3
1345+
chunkId = Math.ceil((data.chunkSize+Number(chunkId)) / upload.uploader.fileUploadParam.maxChunkSize);
1346+
data.headers['X-Chunking-Destination'] = self.davClient._buildUrl(upload.getTargetDestination());
1347+
}
1348+
13291349
data.url = OC.getRootPath() +
13301350
'/remote.php/dav/uploads' +
13311351
'/' + OC.getCurrentUser().uid +

‎apps/files/js/jquery.fileupload.js

+6
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,12 @@
733733
promise = dfd.promise(),
734734
jqXHR,
735735
upload;
736+
737+
// Dynamically adjust the chunk size for Chunking V2 to fit into the 10000 chunk limit
738+
if ((OC.getCapabilities().dav.chunking === '2.0') && file.size/mcs > 10000) {
739+
mcs = Math.ceil(file.size/10000)
740+
}
741+
736742
if (!(this._isXHRUpload(options) && slice && (ub || mcs < fs)) ||
737743
options.data) {
738744
return false;

‎build/psalm-baseline.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?xml version="1.0" encoding="UTF-8"?>
1+
s<?xml version="1.0" encoding="UTF-8"?>
22
<files psalm-version="4.18.1@dda05fa913f4dc6eb3386f2f7ce5a45d37a71bcb">
33
<file src="3rdparty/sabre/dav/lib/CalDAV/Calendar.php">
44
<MoreSpecificImplementedParamType occurrences="1">

‎core/src/files/client.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ import escapeHTML from 'escape-html'
758758
return promise
759759
},
760760

761-
_simpleCall: function(method, path) {
761+
_simpleCall: function(method, path, headers) {
762762
if (!path) {
763763
throw 'Missing argument "path"'
764764
}
@@ -769,7 +769,8 @@ import escapeHTML from 'escape-html'
769769

770770
this._client.request(
771771
method,
772-
this._buildUrl(path)
772+
this._buildUrl(path),
773+
headers ? headers : {}
773774
).then(
774775
function(result) {
775776
if (self._isSuccessStatus(result.status)) {
@@ -790,8 +791,8 @@ import escapeHTML from 'escape-html'
790791
*
791792
* @returns {Promise}
792793
*/
793-
createDirectory: function(path) {
794-
return this._simpleCall('MKCOL', path)
794+
createDirectory: function(path, headers) {
795+
return this._simpleCall('MKCOL', path, headers)
795796
},
796797

797798
/**

‎dist/core-files_client.js

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/core-files_client.js.map

+2-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/composer/composer/autoload_classmap.php

+3
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@
337337
'OCP\\Files\\Notify\\INotifyHandler' => $baseDir . '/lib/public/Files/Notify/INotifyHandler.php',
338338
'OCP\\Files\\Notify\\IRenameChange' => $baseDir . '/lib/public/Files/Notify/IRenameChange.php',
339339
'OCP\\Files\\ObjectStore\\IObjectStore' => $baseDir . '/lib/public/Files/ObjectStore/IObjectStore.php',
340+
'OCP\\Files\\ObjectStore\\IObjectStoreMultiPartUpload' => $baseDir . '/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php',
340341
'OCP\\Files\\ReservedWordException' => $baseDir . '/lib/public/Files/ReservedWordException.php',
341342
'OCP\\Files\\Search\\ISearchBinaryOperator' => $baseDir . '/lib/public/Files/Search/ISearchBinaryOperator.php',
342343
'OCP\\Files\\Search\\ISearchComparison' => $baseDir . '/lib/public/Files/Search/ISearchComparison.php',
@@ -354,9 +355,11 @@
354355
'OCP\\Files\\StorageInvalidException' => $baseDir . '/lib/public/Files/StorageInvalidException.php',
355356
'OCP\\Files\\StorageNotAvailableException' => $baseDir . '/lib/public/Files/StorageNotAvailableException.php',
356357
'OCP\\Files\\StorageTimeoutException' => $baseDir . '/lib/public/Files/StorageTimeoutException.php',
358+
'OCP\\Files\\Storage\\IChunkedFileWrite' => $baseDir . '/lib/public/Files/Storage/IChunkedFileWrite.php',
357359
'OCP\\Files\\Storage\\IDisableEncryptionStorage' => $baseDir . '/lib/public/Files/Storage/IDisableEncryptionStorage.php',
358360
'OCP\\Files\\Storage\\ILockingStorage' => $baseDir . '/lib/public/Files/Storage/ILockingStorage.php',
359361
'OCP\\Files\\Storage\\INotifyStorage' => $baseDir . '/lib/public/Files/Storage/INotifyStorage.php',
362+
'OCP\\Files\\Storage\\IProcessingCallbackStorage' => $baseDir . '/lib/public/Files/Storage/IProcessingCallbackStorage.php',
360363
'OCP\\Files\\Storage\\IReliableEtagStorage' => $baseDir . '/lib/public/Files/Storage/IReliableEtagStorage.php',
361364
'OCP\\Files\\Storage\\IStorage' => $baseDir . '/lib/public/Files/Storage/IStorage.php',
362365
'OCP\\Files\\Storage\\IStorageFactory' => $baseDir . '/lib/public/Files/Storage/IStorageFactory.php',

‎lib/composer/composer/autoload_static.php

+3
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
370370
'OCP\\Files\\Notify\\INotifyHandler' => __DIR__ . '/../../..' . '/lib/public/Files/Notify/INotifyHandler.php',
371371
'OCP\\Files\\Notify\\IRenameChange' => __DIR__ . '/../../..' . '/lib/public/Files/Notify/IRenameChange.php',
372372
'OCP\\Files\\ObjectStore\\IObjectStore' => __DIR__ . '/../../..' . '/lib/public/Files/ObjectStore/IObjectStore.php',
373+
'OCP\\Files\\ObjectStore\\IObjectStoreMultiPartUpload' => __DIR__ . '/../../..' . '/lib/public/Files/ObjectStore/IObjectStoreMultiPartUpload.php',
373374
'OCP\\Files\\ReservedWordException' => __DIR__ . '/../../..' . '/lib/public/Files/ReservedWordException.php',
374375
'OCP\\Files\\Search\\ISearchBinaryOperator' => __DIR__ . '/../../..' . '/lib/public/Files/Search/ISearchBinaryOperator.php',
375376
'OCP\\Files\\Search\\ISearchComparison' => __DIR__ . '/../../..' . '/lib/public/Files/Search/ISearchComparison.php',
@@ -387,9 +388,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
387388
'OCP\\Files\\StorageInvalidException' => __DIR__ . '/../../..' . '/lib/public/Files/StorageInvalidException.php',
388389
'OCP\\Files\\StorageNotAvailableException' => __DIR__ . '/../../..' . '/lib/public/Files/StorageNotAvailableException.php',
389390
'OCP\\Files\\StorageTimeoutException' => __DIR__ . '/../../..' . '/lib/public/Files/StorageTimeoutException.php',
391+
'OCP\\Files\\Storage\\IChunkedFileWrite' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IChunkedFileWrite.php',
390392
'OCP\\Files\\Storage\\IDisableEncryptionStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IDisableEncryptionStorage.php',
391393
'OCP\\Files\\Storage\\ILockingStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/ILockingStorage.php',
392394
'OCP\\Files\\Storage\\INotifyStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/INotifyStorage.php',
395+
'OCP\\Files\\Storage\\IProcessingCallbackStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IProcessingCallbackStorage.php',
393396
'OCP\\Files\\Storage\\IReliableEtagStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IReliableEtagStorage.php',
394397
'OCP\\Files\\Storage\\IStorage' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IStorage.php',
395398
'OCP\\Files\\Storage\\IStorageFactory' => __DIR__ . '/../../..' . '/lib/public/Files/Storage/IStorageFactory.php',

‎lib/private/Files/ObjectStore/ObjectStoreStorage.php

+135-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
*/
3030
namespace OC\Files\ObjectStore;
3131

32+
use Aws\S3\Exception\S3Exception;
33+
use Aws\S3\Exception\S3MultipartUploadException;
3234
use Icewind\Streams\CallbackWrapper;
3335
use Icewind\Streams\CountWrapper;
3436
use Icewind\Streams\IteratorDirectory;
@@ -38,11 +40,15 @@
3840
use OCP\Files\Cache\ICache;
3941
use OCP\Files\Cache\ICacheEntry;
4042
use OCP\Files\FileInfo;
43+
use OCP\Files\GenericFileException;
4144
use OCP\Files\NotFoundException;
4245
use OCP\Files\ObjectStore\IObjectStore;
46+
use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload;
47+
use OCP\Files\Storage\IChunkedFileWrite;
48+
use OCP\Files\Storage\IProcessingCallbackStorage;
4349
use OCP\Files\Storage\IStorage;
4450

45-
class ObjectStoreStorage extends \OC\Files\Storage\Common {
51+
class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFileWrite, IProcessingCallbackStorage {
4652
use CopyDirectory;
4753

4854
/**
@@ -62,6 +68,12 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common {
6268

6369
private $logger;
6470

71+
/** @var ICache */
72+
private $uploadCache;
73+
74+
/** @var array */
75+
private $processingCallbacks;
76+
6577
public function __construct($params) {
6678
if (isset($params['objectstore']) && $params['objectstore'] instanceof IObjectStore) {
6779
$this->objectStore = $params['objectstore'];
@@ -82,6 +94,7 @@ public function __construct($params) {
8294
}
8395

8496
$this->logger = \OC::$server->getLogger();
97+
$this->uploadCache = \OC::$server->getMemCacheFactory()->createDistributed('objectstore');
8598
}
8699

87100
public function mkdir($path) {
@@ -614,4 +627,125 @@ private function copyFile(ICacheEntry $sourceEntry, string $to) {
614627
throw $e;
615628
}
616629
}
630+
631+
public function beginChunkedFile(string $targetPath): string {
632+
$this->validateUploadCache();
633+
if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) {
634+
throw new GenericFileException('Object store does not support multipart upload');
635+
}
636+
$cacheEntry = $this->getCache()->get($targetPath);
637+
$urn = $this->getURN($cacheEntry->getId());
638+
$uploadId = $this->objectStore->initiateMultipartUpload($urn);
639+
$this->uploadCache->set($this->getUploadCacheKey($urn, $uploadId, 'uploadId'), $uploadId);
640+
return $uploadId;
641+
}
642+
643+
/**
644+
*
645+
* @throws GenericFileException
646+
*/
647+
public function putChunkedFilePart(string $targetPath, string $writeToken, string $chunkId, $data, $size = null): void {
648+
$this->validateUploadCache();
649+
if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) {
650+
throw new GenericFileException('Object store does not support multipart upload');
651+
}
652+
$cacheEntry = $this->getCache()->get($targetPath);
653+
$urn = $this->getURN($cacheEntry->getId());
654+
$uploadId = $this->uploadCache->get($this->getUploadCacheKey($urn, $writeToken, 'uploadId'));
655+
656+
$result = $this->objectStore->uploadMultipartPart($urn, $uploadId, (int)$chunkId, $data, $size);
657+
658+
$parts = $this->uploadCache->get($this->getUploadCacheKey($urn, $uploadId, 'parts'));
659+
if (!$parts) {
660+
$parts = [];
661+
}
662+
$parts[$chunkId] = [
663+
'PartNumber' => $chunkId,
664+
'ETag' => trim($result->get('ETag'), '"')
665+
];
666+
$this->uploadCache->set($this->getUploadCacheKey($urn, $uploadId, 'parts'), $parts);
667+
}
668+
669+
public function processingCallback(string $method, callable $callback): void {
670+
if (in_array($method, ['writeChunkedFile'])) {
671+
if (!isset($this->processingCallbacks[$method])) {
672+
$this->processingCallbacks[$method] = [];
673+
}
674+
$this->processingCallbacks[$method][] = $callback;
675+
return;
676+
}
677+
throw new \Exception('Invalid handler method for processing callback');
678+
}
679+
680+
public function writeChunkedFile(string $targetPath, string $writeToken): int {
681+
$this->validateUploadCache();
682+
if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) {
683+
throw new GenericFileException('Object store does not support multipart upload');
684+
}
685+
$cacheEntry = $this->getCache()->get($targetPath);
686+
$urn = $this->getURN($cacheEntry->getId());
687+
$uploadId = $this->uploadCache->get($this->getUploadCacheKey($urn, $writeToken, 'uploadId'));
688+
$parts = $this->uploadCache->get($this->getUploadCacheKey($urn, $uploadId, 'parts'));
689+
$sortedParts = array_values($parts);
690+
sort($sortedParts);
691+
try {
692+
if ($this->objectStore instanceof S3) {
693+
$size = $this->objectStore->completeMultipartUpload($urn, $uploadId, $sortedParts, function () {
694+
foreach ($this->processingCallbacks['writeChunkedFile'] as $callback) {
695+
$callback();
696+
}
697+
});
698+
} else {
699+
$size = $this->objectStore->completeMultipartUpload($urn, $uploadId, array_values($parts));
700+
}
701+
$stat = $this->stat($targetPath);
702+
$mtime = time();
703+
if (is_array($stat)) {
704+
$stat['size'] = $size;
705+
$stat['mtime'] = $mtime;
706+
$stat['mimetype'] = $this->getMimeType($targetPath);
707+
$this->getCache()->update($stat['fileid'], $stat);
708+
}
709+
} catch (S3MultipartUploadException | S3Exception $e) {
710+
$this->objectStore->abortMultipartUpload($urn, $uploadId);
711+
$this->logger->logException($e, [
712+
'app' => 'objectstore',
713+
'message' => 'Could not compete multipart upload ' . $urn. ' with uploadId ' . $uploadId
714+
]);
715+
throw new GenericFileException('Could not write chunked file');
716+
} finally {
717+
$this->clearCache($urn, $uploadId);
718+
}
719+
return $size;
720+
}
721+
722+
public function cancelChunkedFile(string $targetPath, string $writeToken): void {
723+
$this->validateUploadCache();
724+
if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) {
725+
throw new GenericFileException('Object store does not support multipart upload');
726+
}
727+
$cacheEntry = $this->getCache()->get($targetPath);
728+
$urn = $this->getURN($cacheEntry->getId());
729+
$uploadId = $this->uploadCache->get($this->getUploadCacheKey($urn, $writeToken, 'uploadId'));
730+
$this->objectStore->abortMultipartUpload($urn, $uploadId);
731+
$this->clearCache($urn, $uploadId);
732+
}
733+
734+
/**
735+
* @throws GenericFileException
736+
*/
737+
private function validateUploadCache(): void {
738+
if ($this->uploadCache instanceof NullCache || $this->uploadCache instanceof ArrayCache) {
739+
throw new GenericFileException('ChunkedFileWrite not available: A cross-request persistent cache is required');
740+
}
741+
}
742+
743+
private function getUploadCacheKey($urn, $uploadId, $key = null): string {
744+
return $urn . '-' . $uploadId . '-' . ($key ? $key . '-' : '');
745+
}
746+
747+
private function clearCache($urn, $uploadId): void {
748+
$this->uploadCache->remove($this->getUploadCacheKey($urn, $uploadId, 'uploadId'));
749+
$this->uploadCache->remove($this->getUploadCacheKey($urn, $uploadId, 'parts'));
750+
}
617751
}

‎lib/private/Files/ObjectStore/S3.php

+69-1
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@
2323
*/
2424
namespace OC\Files\ObjectStore;
2525

26+
use Aws\Result;
27+
use Exception;
2628
use OCP\Files\ObjectStore\IObjectStore;
29+
use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload;
2730

28-
class S3 implements IObjectStore {
31+
class S3 implements IObjectStore, IObjectStoreMultiPartUpload {
2932
use S3ConnectionTrait;
3033
use S3ObjectTrait;
3134

@@ -41,4 +44,69 @@ public function __construct($parameters) {
4144
public function getStorageId() {
4245
return $this->id;
4346
}
47+
48+
public function initiateMultipartUpload(string $urn): string {
49+
$upload = $this->getConnection()->createMultipartUpload([
50+
'Bucket' => $this->bucket,
51+
'Key' => $urn,
52+
]);
53+
$uploadId = $upload->get('UploadId');
54+
if ($uploadId === null) {
55+
throw new Exception('No upload id returned');
56+
}
57+
return (string)$uploadId;
58+
}
59+
60+
public function uploadMultipartPart(string $urn, string $uploadId, int $partId, $stream, $size): Result {
61+
return $this->getConnection()->uploadPart([
62+
'Body' => $stream,
63+
'Bucket' => $this->bucket,
64+
'Key' => $urn,
65+
'ContentLength' => $size,
66+
'PartNumber' => $partId,
67+
'UploadId' => $uploadId,
68+
]);
69+
}
70+
71+
public function getMultipartUploads(string $urn, string $uploadId): array {
72+
$parts = $this->getConnection()->listParts([
73+
'Bucket' => $this->bucket,
74+
'Key' => $urn,
75+
'UploadId' => $uploadId
76+
]);
77+
return array_map(function ($part) {
78+
return $part;
79+
}, $parts->get('Parts'));
80+
}
81+
82+
public function completeMultipartUpload(string $urn, string $uploadId, array $result, callable $processingCallback = null): int {
83+
$this->getConnection()->completeMultipartUpload([
84+
'Bucket' => $this->bucket,
85+
'Key' => $urn,
86+
'UploadId' => $uploadId,
87+
'MultipartUpload' => ['Parts' => $result],
88+
'@http' => [
89+
// the progress callback is called by CURLOPT_PROGRESSFUNCTION which would get called regularly
90+
// https://curl.se/libcurl/c/CURLOPT_PROGRESSFUNCTION.html
91+
'progress' => function (/* $downloadTotalSize, $downloadSizeSoFar, $uploadTotalSize, $uploadSizeSoFar */) use ($processingCallback) {
92+
if ($processingCallback) {
93+
$processingCallback();
94+
}
95+
}
96+
]
97+
]);
98+
$stat = $this->getConnection()->headObject([
99+
'Bucket' => $this->bucket,
100+
'Key' => $urn,
101+
]);
102+
return (int)$stat->get('ContentLength');
103+
}
104+
105+
public function abortMultipartUpload($urn, $uploadId): void {
106+
$this->getConnection()->abortMultipartUpload([
107+
'Bucket' => $this->bucket,
108+
'Key' => $urn,
109+
'UploadId' => $uploadId
110+
]);
111+
}
44112
}

‎lib/private/Files/View.php

+18
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@
6262
use OCP\Files\Mount\IMountPoint;
6363
use OCP\Files\NotFoundException;
6464
use OCP\Files\ReservedWordException;
65+
use OCP\Files\Storage\IChunkedFileWrite;
6566
use OCP\Files\Storage\IStorage;
67+
use OCP\Files\StorageInvalidException;
6668
use OCP\IUser;
6769
use OCP\Lock\ILockingProvider;
6870
use OCP\Lock\LockedException;
@@ -717,6 +719,22 @@ public function file_put_contents($path, $data) {
717719
}
718720
}
719721

722+
/**
723+
* @param string $path
724+
* @param string $chunkToken
725+
* @return false|mixed|null
726+
* @throws LockedException
727+
* @throws StorageInvalidException
728+
*/
729+
public function writeChunkedFile(string $path, string $chunkToken) {
730+
/** @var IStorage|null $storage */
731+
[$storage, ] = Filesystem::resolvePath($path);
732+
if (!$storage || !$storage->instanceOfStorage(IChunkedFileWrite::class)) {
733+
throw new StorageInvalidException('path is not a chunked file write storage');
734+
}
735+
return $this->basicOperation('writeChunkedFile', $path, ['update', 'write'], $chunkToken);
736+
}
737+
720738
/**
721739
* @param string $path
722740
* @return bool|mixed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
/*
3+
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
4+
*
5+
* @author Julius Härtl <jus@bitgrid.net>
6+
*
7+
* @license GNU AGPL version 3 or any later version
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as
11+
* published by the Free Software Foundation, either version 3 of the
12+
* License, or (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*
22+
*/
23+
24+
declare(strict_types=1);
25+
26+
27+
namespace OCP\Files\ObjectStore;
28+
29+
use Aws\Result;
30+
31+
/**
32+
* @since 23.0.0
33+
*/
34+
interface IObjectStoreMultiPartUpload {
35+
/**
36+
* @since 23.0.0
37+
*/
38+
public function initiateMultipartUpload(string $urn): string;
39+
40+
/**
41+
* @since 23.0.0
42+
*/
43+
public function uploadMultipartPart(string $urn, string $uploadId, int $partId, $stream, $size): Result;
44+
45+
/**
46+
* @since 23.0.0
47+
*/
48+
public function completeMultipartUpload(string $urn, string $uploadId, array $result): int;
49+
50+
/**
51+
* @since 23.0.0
52+
*/
53+
public function abortMultipartUpload(string $urn, string $uploadId): void;
54+
55+
/**
56+
* @since 23.0.0
57+
*/
58+
public function getMultipartUploads(string $urn, string $uploadId): array;
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
/*
3+
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
4+
*
5+
* @author Julius Härtl <jus@bitgrid.net>
6+
*
7+
* @license GNU AGPL version 3 or any later version
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU Affero General Public License as
11+
* published by the Free Software Foundation, either version 3 of the
12+
* License, or (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU Affero General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU Affero General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*
22+
*/
23+
24+
declare(strict_types=1);
25+
26+
27+
namespace OCP\Files\Storage;
28+
29+
use OCP\Files\GenericFileException;
30+
31+
/**
32+
* @since 23.0.0
33+
*/
34+
interface IChunkedFileWrite extends IStorage {
35+
36+
/**
37+
* @param string $targetPath Relative target path in the storage
38+
* @return string writeToken to be used with the other methods to uniquely identify the file write operation
39+
* @throws GenericFileException
40+
* @since 23.0.0
41+
*/
42+
public function beginChunkedFile(string $targetPath): string;
43+
44+
/**
45+
* @param string $targetPath
46+
* @param string $writeToken
47+
* @param string $chunkId
48+
* @param resource $data
49+
* @param int|null $size
50+
* @throws GenericFileException
51+
* @since 23.0.0
52+
*/
53+
public function putChunkedFilePart(string $targetPath, string $writeToken, string $chunkId, $data, int $size = null): void;
54+
55+
/**
56+
* @param string $targetPath
57+
* @param string $writeToken
58+
* @return int
59+
* @throws GenericFileException
60+
* @since 23.0.0
61+
*/
62+
public function writeChunkedFile(string $targetPath, string $writeToken): int;
63+
64+
/**
65+
* @param string $targetPath
66+
* @param string $writeToken
67+
* @throws GenericFileException
68+
* @since 23.0.0
69+
*/
70+
public function cancelChunkedFile(string $targetPath, string $writeToken): void;
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
7+
*
8+
* @author Julius Härtl <jus@bitgrid.net>
9+
*
10+
* @license GNU AGPL version 3 or any later version
11+
*
12+
* This program is free software: you can redistribute it and/or modify
13+
* it under the terms of the GNU Affero General Public License as
14+
* published by the Free Software Foundation, either version 3 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This program is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+
* GNU Affero General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Affero General Public License
23+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
24+
*
25+
*/
26+
namespace OCP\Files\Storage;
27+
28+
use OCP\Files\GenericFileException;
29+
30+
/**
31+
* Interface that adds the ability to register processing callbacks for storage operation
32+
*
33+
* @since 23.0.0
34+
*/
35+
interface IProcessingCallbackStorage extends IStorage {
36+
/**
37+
* Register a callback for a processing storage operation
38+
*
39+
* @param string $method
40+
* @param callable $callback being called regularly during the storage operation
41+
* @return void
42+
* @throws GenericFileException
43+
* @since 23.0.0
44+
*/
45+
public function processingCallback(string $method, callable $callback): void;
46+
}

0 commit comments

Comments
 (0)
Please sign in to comment.