ChunkingV2Plugin.php 14 KB

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