ChunkingV2Plugin.php 14 KB

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