ChunkingV2Plugin.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\DAV\Upload;
  8. use Exception;
  9. use InvalidArgumentException;
  10. use OC\Files\Filesystem;
  11. use OC\Files\ObjectStore\ObjectStoreStorage;
  12. use OC\Files\View;
  13. use OC\Memcache\Memcached;
  14. use OC\Memcache\Redis;
  15. use OC_Hook;
  16. use OCA\DAV\Connector\Sabre\Directory;
  17. use OCA\DAV\Connector\Sabre\File;
  18. use OCP\AppFramework\Http;
  19. use OCP\Files\IMimeTypeDetector;
  20. use OCP\Files\IRootFolder;
  21. use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload;
  22. use OCP\Files\Storage\IChunkedFileWrite;
  23. use OCP\Files\StorageInvalidException;
  24. use OCP\ICache;
  25. use OCP\ICacheFactory;
  26. use OCP\IConfig;
  27. use OCP\Lock\ILockingProvider;
  28. use Sabre\DAV\Exception\BadRequest;
  29. use Sabre\DAV\Exception\InsufficientStorage;
  30. use Sabre\DAV\Exception\NotFound;
  31. use Sabre\DAV\Exception\PreconditionFailed;
  32. use Sabre\DAV\ICollection;
  33. use Sabre\DAV\INode;
  34. use Sabre\DAV\Server;
  35. use Sabre\DAV\ServerPlugin;
  36. use Sabre\HTTP\RequestInterface;
  37. use Sabre\HTTP\ResponseInterface;
  38. use Sabre\Uri;
  39. class ChunkingV2Plugin extends ServerPlugin {
  40. /** @var Server */
  41. private $server;
  42. /** @var UploadFolder */
  43. private $uploadFolder;
  44. /** @var ICache */
  45. private $cache;
  46. private ?string $uploadId = null;
  47. private ?string $uploadPath = null;
  48. private const TEMP_TARGET = '.target';
  49. public const CACHE_KEY = 'chunking-v2';
  50. public const UPLOAD_TARGET_PATH = 'upload-target-path';
  51. public const UPLOAD_TARGET_ID = 'upload-target-id';
  52. public const UPLOAD_ID = 'upload-id';
  53. private const DESTINATION_HEADER = 'Destination';
  54. public function __construct(ICacheFactory $cacheFactory) {
  55. $this->cache = $cacheFactory->createDistributed(self::CACHE_KEY);
  56. }
  57. /**
  58. * @inheritdoc
  59. */
  60. public function initialize(Server $server) {
  61. $server->on('afterMethod:MKCOL', [$this, 'afterMkcol']);
  62. $server->on('beforeMethod:PUT', [$this, 'beforePut']);
  63. $server->on('beforeMethod:DELETE', [$this, 'beforeDelete']);
  64. $server->on('beforeMove', [$this, 'beforeMove'], 90);
  65. $this->server = $server;
  66. }
  67. /**
  68. * @param string $path
  69. * @param bool $createIfNotExists
  70. * @return FutureFile|UploadFile|ICollection|INode
  71. */
  72. private function getUploadFile(string $path, bool $createIfNotExists = false) {
  73. try {
  74. $actualFile = $this->server->tree->getNodeForPath($path);
  75. // Only directly upload to the target file if it is on the same storage
  76. // There may be further potential to optimize here by also uploading
  77. // to other storages directly. This would require to also carefully pick
  78. // the storage/path used in getStorage()
  79. if ($actualFile instanceof File && $this->uploadFolder->getStorage()->getId() === $actualFile->getNode()->getStorage()->getId()) {
  80. return $actualFile;
  81. }
  82. } catch (NotFound $e) {
  83. // If there is no target file we upload to the upload folder first
  84. }
  85. // Use file in the upload directory that will be copied or moved afterwards
  86. if ($createIfNotExists) {
  87. $this->uploadFolder->createFile(self::TEMP_TARGET);
  88. }
  89. /** @var UploadFile $uploadFile */
  90. $uploadFile = $this->uploadFolder->getChild(self::TEMP_TARGET);
  91. return $uploadFile->getFile();
  92. }
  93. public function afterMkcol(RequestInterface $request, ResponseInterface $response): bool {
  94. try {
  95. $this->prepareUpload($request->getPath());
  96. $this->checkPrerequisites(false);
  97. } catch (BadRequest|StorageInvalidException|NotFound $e) {
  98. return true;
  99. }
  100. $this->uploadPath = $this->server->calculateUri($this->server->httpRequest->getHeader(self::DESTINATION_HEADER));
  101. $targetFile = $this->getUploadFile($this->uploadPath, true);
  102. [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath);
  103. $this->uploadId = $storage->startChunkedWrite($storagePath);
  104. $this->cache->set($this->uploadFolder->getName(), [
  105. self::UPLOAD_ID => $this->uploadId,
  106. self::UPLOAD_TARGET_PATH => $this->uploadPath,
  107. self::UPLOAD_TARGET_ID => $targetFile->getId(),
  108. ], 86400);
  109. $response->setStatus(Http::STATUS_CREATED);
  110. return true;
  111. }
  112. public function beforePut(RequestInterface $request, ResponseInterface $response): bool {
  113. try {
  114. $this->prepareUpload(dirname($request->getPath()));
  115. $this->checkPrerequisites();
  116. } catch (StorageInvalidException|BadRequest|NotFound $e) {
  117. return true;
  118. }
  119. [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath);
  120. $chunkName = basename($request->getPath());
  121. $partId = is_numeric($chunkName) ? (int)$chunkName : -1;
  122. if (!($partId >= 1 && $partId <= 10000)) {
  123. throw new BadRequest('Invalid chunk name, must be numeric between 1 and 10000');
  124. }
  125. $uploadFile = $this->getUploadFile($this->uploadPath);
  126. $tempTargetFile = null;
  127. $additionalSize = (int)$request->getHeader('Content-Length');
  128. if ($this->uploadFolder->childExists(self::TEMP_TARGET) && $this->uploadPath) {
  129. /** @var UploadFile $tempTargetFile */
  130. $tempTargetFile = $this->uploadFolder->getChild(self::TEMP_TARGET);
  131. [$destinationDir, $destinationName] = Uri\split($this->uploadPath);
  132. /** @var Directory $destinationParent */
  133. $destinationParent = $this->server->tree->getNodeForPath($destinationDir);
  134. $free = $destinationParent->getNode()->getFreeSpace();
  135. $newSize = $tempTargetFile->getSize() + $additionalSize;
  136. if ($free >= 0 && ($tempTargetFile->getSize() > $free || $newSize > $free)) {
  137. throw new InsufficientStorage("Insufficient space in $this->uploadPath");
  138. }
  139. }
  140. $stream = $request->getBodyAsStream();
  141. $storage->putChunkedWritePart($storagePath, $this->uploadId, (string)$partId, $stream, $additionalSize);
  142. $storage->getCache()->update($uploadFile->getId(), ['size' => $uploadFile->getSize() + $additionalSize]);
  143. if ($tempTargetFile) {
  144. $storage->getPropagator()->propagateChange($tempTargetFile->getInternalPath(), time(), $additionalSize);
  145. }
  146. $response->setStatus(201);
  147. return false;
  148. }
  149. public function beforeMove($sourcePath, $destination): bool {
  150. try {
  151. $this->prepareUpload(dirname($sourcePath));
  152. $this->checkPrerequisites();
  153. } catch (StorageInvalidException|BadRequest|NotFound|PreconditionFailed $e) {
  154. return true;
  155. }
  156. [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath);
  157. $targetFile = $this->getUploadFile($this->uploadPath);
  158. [$destinationDir, $destinationName] = Uri\split($destination);
  159. /** @var Directory $destinationParent */
  160. $destinationParent = $this->server->tree->getNodeForPath($destinationDir);
  161. $destinationExists = $destinationParent->childExists($destinationName);
  162. // allow sync clients to send the modification and creation time along in a header
  163. $updateFileInfo = [];
  164. if ($this->server->httpRequest->getHeader('X-OC-MTime') !== null) {
  165. $updateFileInfo['mtime'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-MTime'));
  166. $this->server->httpResponse->setHeader('X-OC-MTime', 'accepted');
  167. }
  168. if ($this->server->httpRequest->getHeader('X-OC-CTime') !== null) {
  169. $updateFileInfo['creation_time'] = $this->sanitizeMtime($this->server->httpRequest->getHeader('X-OC-CTime'));
  170. $this->server->httpResponse->setHeader('X-OC-CTime', 'accepted');
  171. }
  172. $updateFileInfo['mimetype'] = \OCP\Server::get(IMimeTypeDetector::class)->detectPath($destinationName);
  173. if ($storage->instanceOfStorage(ObjectStoreStorage::class) && $storage->getObjectStore() instanceof IObjectStoreMultiPartUpload) {
  174. /** @var ObjectStoreStorage $storage */
  175. /** @var IObjectStoreMultiPartUpload $objectStore */
  176. $objectStore = $storage->getObjectStore();
  177. $parts = $objectStore->getMultipartUploads($storage->getURN($targetFile->getId()), $this->uploadId);
  178. $size = 0;
  179. foreach ($parts as $part) {
  180. $size += $part['Size'];
  181. }
  182. $free = $destinationParent->getNode()->getFreeSpace();
  183. if ($free >= 0 && ($size > $free)) {
  184. throw new InsufficientStorage("Insufficient space in $this->uploadPath");
  185. }
  186. }
  187. $destinationInView = $destinationParent->getFileInfo()->getPath() . '/' . $destinationName;
  188. $this->completeChunkedWrite($destinationInView);
  189. $rootView = new View();
  190. $rootView->putFileInfo($destinationInView, $updateFileInfo);
  191. $sourceNode = $this->server->tree->getNodeForPath($sourcePath);
  192. if ($sourceNode instanceof FutureFile) {
  193. $this->uploadFolder->delete();
  194. }
  195. $this->server->emit('afterMove', [$sourcePath, $destination]);
  196. $this->server->emit('afterUnbind', [$sourcePath]);
  197. $this->server->emit('afterBind', [$destination]);
  198. $response = $this->server->httpResponse;
  199. $response->setHeader('Content-Type', 'application/xml; charset=utf-8');
  200. $response->setHeader('Content-Length', '0');
  201. $response->setStatus($destinationExists ? Http::STATUS_NO_CONTENT : Http::STATUS_CREATED);
  202. return false;
  203. }
  204. public function beforeDelete(RequestInterface $request, ResponseInterface $response) {
  205. try {
  206. $this->prepareUpload(dirname($request->getPath()));
  207. $this->checkPrerequisites();
  208. } catch (StorageInvalidException|BadRequest|NotFound $e) {
  209. return true;
  210. }
  211. [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath);
  212. $storage->cancelChunkedWrite($storagePath, $this->uploadId);
  213. return true;
  214. }
  215. /**
  216. * @throws BadRequest
  217. * @throws PreconditionFailed
  218. * @throws StorageInvalidException
  219. */
  220. private function checkPrerequisites(bool $checkUploadMetadata = true): void {
  221. $distributedCacheConfig = \OCP\Server::get(IConfig::class)->getSystemValue('memcache.distributed', null);
  222. if ($distributedCacheConfig === null || (!$this->cache instanceof Redis && !$this->cache instanceof Memcached)) {
  223. throw new BadRequest('Skipping chunking v2 since no proper distributed cache is available');
  224. }
  225. if (!$this->uploadFolder instanceof UploadFolder || empty($this->server->httpRequest->getHeader(self::DESTINATION_HEADER))) {
  226. throw new BadRequest('Skipping chunked file writing as the destination header was not passed');
  227. }
  228. if (!$this->uploadFolder->getStorage()->instanceOfStorage(IChunkedFileWrite::class)) {
  229. throw new StorageInvalidException('Storage does not support chunked file writing');
  230. }
  231. if ($this->uploadFolder->getStorage()->instanceOfStorage(ObjectStoreStorage::class) && !$this->uploadFolder->getStorage()->getObjectStore() instanceof IObjectStoreMultiPartUpload) {
  232. throw new StorageInvalidException('Storage does not support multi part uploads');
  233. }
  234. if ($checkUploadMetadata) {
  235. if ($this->uploadId === null || $this->uploadPath === null) {
  236. throw new PreconditionFailed('Missing metadata for chunked upload. The distributed cache does not hold the information of previous requests.');
  237. }
  238. }
  239. }
  240. /**
  241. * @return array [IStorage, string]
  242. */
  243. private function getUploadStorage(string $targetPath): array {
  244. $storage = $this->uploadFolder->getStorage();
  245. $targetFile = $this->getUploadFile($targetPath);
  246. return [$storage, $targetFile->getInternalPath()];
  247. }
  248. protected function sanitizeMtime(string $mtimeFromRequest): int {
  249. if (!is_numeric($mtimeFromRequest)) {
  250. throw new InvalidArgumentException('X-OC-MTime header must be an integer (unix timestamp).');
  251. }
  252. return (int)$mtimeFromRequest;
  253. }
  254. /**
  255. * @throws NotFound
  256. */
  257. public function prepareUpload($path): void {
  258. $this->uploadFolder = $this->server->tree->getNodeForPath($path);
  259. $uploadMetadata = $this->cache->get($this->uploadFolder->getName());
  260. $this->uploadId = $uploadMetadata[self::UPLOAD_ID] ?? null;
  261. $this->uploadPath = $uploadMetadata[self::UPLOAD_TARGET_PATH] ?? null;
  262. }
  263. private function completeChunkedWrite(string $targetAbsolutePath): void {
  264. $uploadFile = $this->getUploadFile($this->uploadPath)->getNode();
  265. [$storage, $storagePath] = $this->getUploadStorage($this->uploadPath);
  266. $rootFolder = \OCP\Server::get(IRootFolder::class);
  267. $exists = $rootFolder->nodeExists($targetAbsolutePath);
  268. $uploadFile->lock(ILockingProvider::LOCK_SHARED);
  269. $this->emitPreHooks($targetAbsolutePath, $exists);
  270. try {
  271. $uploadFile->changeLock(ILockingProvider::LOCK_EXCLUSIVE);
  272. $storage->completeChunkedWrite($storagePath, $this->uploadId);
  273. $uploadFile->changeLock(ILockingProvider::LOCK_SHARED);
  274. } catch (Exception $e) {
  275. $uploadFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
  276. throw $e;
  277. }
  278. // If the file was not uploaded to the user storage directly we need to copy/move it
  279. try {
  280. $uploadFileAbsolutePath = $uploadFile->getFileInfo()->getPath();
  281. if ($uploadFileAbsolutePath !== $targetAbsolutePath) {
  282. $uploadFile = $rootFolder->get($uploadFile->getFileInfo()->getPath());
  283. if ($exists) {
  284. $uploadFile->copy($targetAbsolutePath);
  285. } else {
  286. $uploadFile->move($targetAbsolutePath);
  287. }
  288. }
  289. $this->emitPostHooks($targetAbsolutePath, $exists);
  290. } catch (Exception $e) {
  291. $uploadFile->unlock(ILockingProvider::LOCK_SHARED);
  292. throw $e;
  293. }
  294. }
  295. private function emitPreHooks(string $target, bool $exists): void {
  296. $hookPath = $this->getHookPath($target);
  297. if (!$exists) {
  298. OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [
  299. Filesystem::signal_param_path => $hookPath,
  300. ]);
  301. } else {
  302. OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [
  303. Filesystem::signal_param_path => $hookPath,
  304. ]);
  305. }
  306. OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_write, [
  307. Filesystem::signal_param_path => $hookPath,
  308. ]);
  309. }
  310. private function emitPostHooks(string $target, bool $exists): void {
  311. $hookPath = $this->getHookPath($target);
  312. if (!$exists) {
  313. OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [
  314. Filesystem::signal_param_path => $hookPath,
  315. ]);
  316. } else {
  317. OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [
  318. Filesystem::signal_param_path => $hookPath,
  319. ]);
  320. }
  321. OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [
  322. Filesystem::signal_param_path => $hookPath,
  323. ]);
  324. }
  325. private function getHookPath(string $path): ?string {
  326. if (!Filesystem::getView()) {
  327. return $path;
  328. }
  329. return Filesystem::getView()->getRelativePath($path);
  330. }
  331. }