ChunkingV2Plugin.php 14 KB

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