AmazonS3.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\Files_External\Lib\Storage;
  8. use Aws\S3\Exception\S3Exception;
  9. use Icewind\Streams\CallbackWrapper;
  10. use Icewind\Streams\CountWrapper;
  11. use Icewind\Streams\IteratorDirectory;
  12. use OC\Files\Cache\CacheEntry;
  13. use OC\Files\ObjectStore\S3ConnectionTrait;
  14. use OC\Files\ObjectStore\S3ObjectTrait;
  15. use OCP\Cache\CappedMemoryCache;
  16. use OCP\Constants;
  17. use OCP\Files\FileInfo;
  18. use OCP\Files\IMimeTypeDetector;
  19. use OCP\ICache;
  20. use OCP\ICacheFactory;
  21. use OCP\Server;
  22. use Psr\Log\LoggerInterface;
  23. class AmazonS3 extends \OC\Files\Storage\Common {
  24. use S3ConnectionTrait;
  25. use S3ObjectTrait;
  26. private LoggerInterface $logger;
  27. public function needsPartFile() {
  28. return false;
  29. }
  30. /** @var CappedMemoryCache<array|false> */
  31. private CappedMemoryCache $objectCache;
  32. /** @var CappedMemoryCache<bool> */
  33. private CappedMemoryCache $directoryCache;
  34. /** @var CappedMemoryCache<array> */
  35. private CappedMemoryCache $filesCache;
  36. private IMimeTypeDetector $mimeDetector;
  37. private ?bool $versioningEnabled = null;
  38. private ICache $memCache;
  39. public function __construct($parameters) {
  40. parent::__construct($parameters);
  41. $this->parseParams($parameters);
  42. $this->id = 'amazon::external::' . md5($this->params['hostname'] . ':' . $this->params['bucket'] . ':' . $this->params['key']);
  43. $this->objectCache = new CappedMemoryCache();
  44. $this->directoryCache = new CappedMemoryCache();
  45. $this->filesCache = new CappedMemoryCache();
  46. $this->mimeDetector = Server::get(IMimeTypeDetector::class);
  47. /** @var ICacheFactory $cacheFactory */
  48. $cacheFactory = Server::get(ICacheFactory::class);
  49. $this->memCache = $cacheFactory->createLocal('s3-external');
  50. $this->logger = Server::get(LoggerInterface::class);
  51. }
  52. /**
  53. * @param string $path
  54. * @return string correctly encoded path
  55. */
  56. private function normalizePath($path) {
  57. $path = trim($path, '/');
  58. if (!$path) {
  59. $path = '.';
  60. }
  61. return $path;
  62. }
  63. private function isRoot($path) {
  64. return $path === '.';
  65. }
  66. private function cleanKey($path) {
  67. if ($this->isRoot($path)) {
  68. return '/';
  69. }
  70. return $path;
  71. }
  72. private function clearCache() {
  73. $this->objectCache = new CappedMemoryCache();
  74. $this->directoryCache = new CappedMemoryCache();
  75. $this->filesCache = new CappedMemoryCache();
  76. }
  77. private function invalidateCache($key) {
  78. unset($this->objectCache[$key]);
  79. $keys = array_keys($this->objectCache->getData());
  80. $keyLength = strlen($key);
  81. foreach ($keys as $existingKey) {
  82. if (substr($existingKey, 0, $keyLength) === $key) {
  83. unset($this->objectCache[$existingKey]);
  84. }
  85. }
  86. unset($this->filesCache[$key]);
  87. $keys = array_keys($this->directoryCache->getData());
  88. $keyLength = strlen($key);
  89. foreach ($keys as $existingKey) {
  90. if (substr($existingKey, 0, $keyLength) === $key) {
  91. unset($this->directoryCache[$existingKey]);
  92. }
  93. }
  94. unset($this->directoryCache[$key]);
  95. }
  96. /**
  97. * @return array|false
  98. */
  99. private function headObject(string $key) {
  100. if (!isset($this->objectCache[$key])) {
  101. try {
  102. $this->objectCache[$key] = $this->getConnection()->headObject([
  103. 'Bucket' => $this->bucket,
  104. 'Key' => $key
  105. ])->toArray();
  106. } catch (S3Exception $e) {
  107. if ($e->getStatusCode() >= 500) {
  108. throw $e;
  109. }
  110. $this->objectCache[$key] = false;
  111. }
  112. }
  113. if (is_array($this->objectCache[$key]) && !isset($this->objectCache[$key]['Key'])) {
  114. /** @psalm-suppress InvalidArgument Psalm doesn't understand nested arrays well */
  115. $this->objectCache[$key]['Key'] = $key;
  116. }
  117. return $this->objectCache[$key];
  118. }
  119. /**
  120. * Return true if directory exists
  121. *
  122. * There are no folders in s3. A folder like structure could be archived
  123. * by prefixing files with the folder name.
  124. *
  125. * Implementation from flysystem-aws-s3-v3:
  126. * https://github.com/thephpleague/flysystem-aws-s3-v3/blob/8241e9cc5b28f981e0d24cdaf9867f14c7498ae4/src/AwsS3Adapter.php#L670-L694
  127. *
  128. * @param $path
  129. * @return bool
  130. * @throws \Exception
  131. */
  132. private function doesDirectoryExist($path) {
  133. if ($path === '.' || $path === '') {
  134. return true;
  135. }
  136. $path = rtrim($path, '/') . '/';
  137. if (isset($this->directoryCache[$path])) {
  138. return $this->directoryCache[$path];
  139. }
  140. try {
  141. // Maybe this isn't an actual key, but a prefix.
  142. // Do a prefix listing of objects to determine.
  143. $result = $this->getConnection()->listObjectsV2([
  144. 'Bucket' => $this->bucket,
  145. 'Prefix' => $path,
  146. 'MaxKeys' => 1,
  147. ]);
  148. if (isset($result['Contents'])) {
  149. $this->directoryCache[$path] = true;
  150. return true;
  151. }
  152. // empty directories have their own object
  153. $object = $this->headObject($path);
  154. if ($object) {
  155. $this->directoryCache[$path] = true;
  156. return true;
  157. }
  158. } catch (S3Exception $e) {
  159. if ($e->getStatusCode() >= 400 && $e->getStatusCode() < 500) {
  160. $this->directoryCache[$path] = false;
  161. }
  162. throw $e;
  163. }
  164. $this->directoryCache[$path] = false;
  165. return false;
  166. }
  167. /**
  168. * Remove a file or folder
  169. *
  170. * @param string $path
  171. * @return bool
  172. */
  173. protected function remove($path) {
  174. // remember fileType to reduce http calls
  175. $fileType = $this->filetype($path);
  176. if ($fileType === 'dir') {
  177. return $this->rmdir($path);
  178. } elseif ($fileType === 'file') {
  179. return $this->unlink($path);
  180. } else {
  181. return false;
  182. }
  183. }
  184. public function mkdir($path) {
  185. $path = $this->normalizePath($path);
  186. if ($this->is_dir($path)) {
  187. return false;
  188. }
  189. try {
  190. $this->getConnection()->putObject([
  191. 'Bucket' => $this->bucket,
  192. 'Key' => $path . '/',
  193. 'Body' => '',
  194. 'ContentType' => FileInfo::MIMETYPE_FOLDER
  195. ]);
  196. $this->testTimeout();
  197. } catch (S3Exception $e) {
  198. $this->logger->error($e->getMessage(), [
  199. 'app' => 'files_external',
  200. 'exception' => $e,
  201. ]);
  202. return false;
  203. }
  204. $this->invalidateCache($path);
  205. return true;
  206. }
  207. public function file_exists($path) {
  208. return $this->filetype($path) !== false;
  209. }
  210. public function rmdir($path) {
  211. $path = $this->normalizePath($path);
  212. if ($this->isRoot($path)) {
  213. return $this->clearBucket();
  214. }
  215. if (!$this->file_exists($path)) {
  216. return false;
  217. }
  218. $this->invalidateCache($path);
  219. return $this->batchDelete($path);
  220. }
  221. protected function clearBucket() {
  222. $this->clearCache();
  223. return $this->batchDelete();
  224. }
  225. private function batchDelete($path = null) {
  226. // TODO explore using https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.BatchDelete.html
  227. $params = [
  228. 'Bucket' => $this->bucket
  229. ];
  230. if ($path !== null) {
  231. $params['Prefix'] = $path . '/';
  232. }
  233. try {
  234. $connection = $this->getConnection();
  235. // Since there are no real directories on S3, we need
  236. // to delete all objects prefixed with the path.
  237. do {
  238. // instead of the iterator, manually loop over the list ...
  239. $objects = $connection->listObjects($params);
  240. // ... so we can delete the files in batches
  241. if (isset($objects['Contents'])) {
  242. $connection->deleteObjects([
  243. 'Bucket' => $this->bucket,
  244. 'Delete' => [
  245. 'Objects' => $objects['Contents']
  246. ]
  247. ]);
  248. $this->testTimeout();
  249. }
  250. // we reached the end when the list is no longer truncated
  251. } while ($objects['IsTruncated']);
  252. if ($path !== '' && $path !== null) {
  253. $this->deleteObject($path);
  254. }
  255. } catch (S3Exception $e) {
  256. $this->logger->error($e->getMessage(), [
  257. 'app' => 'files_external',
  258. 'exception' => $e,
  259. ]);
  260. return false;
  261. }
  262. return true;
  263. }
  264. public function opendir($path) {
  265. try {
  266. $content = iterator_to_array($this->getDirectoryContent($path));
  267. return IteratorDirectory::wrap(array_map(function (array $item) {
  268. return $item['name'];
  269. }, $content));
  270. } catch (S3Exception $e) {
  271. return false;
  272. }
  273. }
  274. public function stat($path) {
  275. $path = $this->normalizePath($path);
  276. if ($this->is_dir($path)) {
  277. $stat = $this->getDirectoryMetaData($path);
  278. } else {
  279. $object = $this->headObject($path);
  280. if ($object === false) {
  281. return false;
  282. }
  283. $stat = $this->objectToMetaData($object);
  284. }
  285. $stat['atime'] = time();
  286. return $stat;
  287. }
  288. /**
  289. * Return content length for object
  290. *
  291. * When the information is already present (e.g. opendir has been called before)
  292. * this value is return. Otherwise a headObject is emitted.
  293. *
  294. * @param $path
  295. * @return int|mixed
  296. */
  297. private function getContentLength($path) {
  298. if (isset($this->filesCache[$path])) {
  299. return (int)$this->filesCache[$path]['ContentLength'];
  300. }
  301. $result = $this->headObject($path);
  302. if (isset($result['ContentLength'])) {
  303. return (int)$result['ContentLength'];
  304. }
  305. return 0;
  306. }
  307. /**
  308. * Return last modified for object
  309. *
  310. * When the information is already present (e.g. opendir has been called before)
  311. * this value is return. Otherwise a headObject is emitted.
  312. *
  313. * @param $path
  314. * @return mixed|string
  315. */
  316. private function getLastModified($path) {
  317. if (isset($this->filesCache[$path])) {
  318. return $this->filesCache[$path]['LastModified'];
  319. }
  320. $result = $this->headObject($path);
  321. if (isset($result['LastModified'])) {
  322. return $result['LastModified'];
  323. }
  324. return 'now';
  325. }
  326. public function is_dir($path) {
  327. $path = $this->normalizePath($path);
  328. if (isset($this->filesCache[$path])) {
  329. return false;
  330. }
  331. try {
  332. return $this->doesDirectoryExist($path);
  333. } catch (S3Exception $e) {
  334. $this->logger->error($e->getMessage(), [
  335. 'app' => 'files_external',
  336. 'exception' => $e,
  337. ]);
  338. return false;
  339. }
  340. }
  341. public function filetype($path) {
  342. $path = $this->normalizePath($path);
  343. if ($this->isRoot($path)) {
  344. return 'dir';
  345. }
  346. try {
  347. if (isset($this->directoryCache[$path]) && $this->directoryCache[$path]) {
  348. return 'dir';
  349. }
  350. if (isset($this->filesCache[$path]) || $this->headObject($path)) {
  351. return 'file';
  352. }
  353. if ($this->doesDirectoryExist($path)) {
  354. return 'dir';
  355. }
  356. } catch (S3Exception $e) {
  357. $this->logger->error($e->getMessage(), [
  358. 'app' => 'files_external',
  359. 'exception' => $e,
  360. ]);
  361. return false;
  362. }
  363. return false;
  364. }
  365. public function getPermissions($path) {
  366. $type = $this->filetype($path);
  367. if (!$type) {
  368. return 0;
  369. }
  370. return $type === 'dir' ? Constants::PERMISSION_ALL : Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
  371. }
  372. public function unlink($path) {
  373. $path = $this->normalizePath($path);
  374. if ($this->is_dir($path)) {
  375. return $this->rmdir($path);
  376. }
  377. try {
  378. $this->deleteObject($path);
  379. $this->invalidateCache($path);
  380. } catch (S3Exception $e) {
  381. $this->logger->error($e->getMessage(), [
  382. 'app' => 'files_external',
  383. 'exception' => $e,
  384. ]);
  385. return false;
  386. }
  387. return true;
  388. }
  389. public function fopen($path, $mode) {
  390. $path = $this->normalizePath($path);
  391. switch ($mode) {
  392. case 'r':
  393. case 'rb':
  394. // Don't try to fetch empty files
  395. $stat = $this->stat($path);
  396. if (is_array($stat) && isset($stat['size']) && $stat['size'] === 0) {
  397. return fopen('php://memory', $mode);
  398. }
  399. try {
  400. return $this->readObject($path);
  401. } catch (\Exception $e) {
  402. $this->logger->error($e->getMessage(), [
  403. 'app' => 'files_external',
  404. 'exception' => $e,
  405. ]);
  406. return false;
  407. }
  408. case 'w':
  409. case 'wb':
  410. $tmpFile = \OC::$server->getTempManager()->getTemporaryFile();
  411. $handle = fopen($tmpFile, 'w');
  412. return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) {
  413. $this->writeBack($tmpFile, $path);
  414. });
  415. case 'a':
  416. case 'ab':
  417. case 'r+':
  418. case 'w+':
  419. case 'wb+':
  420. case 'a+':
  421. case 'x':
  422. case 'x+':
  423. case 'c':
  424. case 'c+':
  425. if (strrpos($path, '.') !== false) {
  426. $ext = substr($path, strrpos($path, '.'));
  427. } else {
  428. $ext = '';
  429. }
  430. $tmpFile = \OC::$server->getTempManager()->getTemporaryFile($ext);
  431. if ($this->file_exists($path)) {
  432. $source = $this->readObject($path);
  433. file_put_contents($tmpFile, $source);
  434. }
  435. $handle = fopen($tmpFile, $mode);
  436. return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) {
  437. $this->writeBack($tmpFile, $path);
  438. });
  439. }
  440. return false;
  441. }
  442. public function touch($path, $mtime = null) {
  443. if (is_null($mtime)) {
  444. $mtime = time();
  445. }
  446. $metadata = [
  447. 'lastmodified' => gmdate(\DateTime::RFC1123, $mtime)
  448. ];
  449. try {
  450. if ($this->file_exists($path)) {
  451. return false;
  452. }
  453. $mimeType = $this->mimeDetector->detectPath($path);
  454. $this->getConnection()->putObject([
  455. 'Bucket' => $this->bucket,
  456. 'Key' => $this->cleanKey($path),
  457. 'Metadata' => $metadata,
  458. 'Body' => '',
  459. 'ContentType' => $mimeType,
  460. 'MetadataDirective' => 'REPLACE',
  461. ]);
  462. $this->testTimeout();
  463. } catch (S3Exception $e) {
  464. $this->logger->error($e->getMessage(), [
  465. 'app' => 'files_external',
  466. 'exception' => $e,
  467. ]);
  468. return false;
  469. }
  470. $this->invalidateCache($path);
  471. return true;
  472. }
  473. public function copy($source, $target, $isFile = null) {
  474. $source = $this->normalizePath($source);
  475. $target = $this->normalizePath($target);
  476. if ($isFile === true || $this->is_file($source)) {
  477. try {
  478. $this->copyObject($source, $target, [
  479. 'StorageClass' => $this->storageClass,
  480. ]);
  481. $this->testTimeout();
  482. } catch (S3Exception $e) {
  483. $this->logger->error($e->getMessage(), [
  484. 'app' => 'files_external',
  485. 'exception' => $e,
  486. ]);
  487. return false;
  488. }
  489. } else {
  490. $this->remove($target);
  491. try {
  492. $this->mkdir($target);
  493. $this->testTimeout();
  494. } catch (S3Exception $e) {
  495. $this->logger->error($e->getMessage(), [
  496. 'app' => 'files_external',
  497. 'exception' => $e,
  498. ]);
  499. return false;
  500. }
  501. foreach ($this->getDirectoryContent($source) as $item) {
  502. $childSource = $source . '/' . $item['name'];
  503. $childTarget = $target . '/' . $item['name'];
  504. $this->copy($childSource, $childTarget, $item['mimetype'] !== FileInfo::MIMETYPE_FOLDER);
  505. }
  506. }
  507. $this->invalidateCache($target);
  508. return true;
  509. }
  510. public function rename($source, $target) {
  511. $source = $this->normalizePath($source);
  512. $target = $this->normalizePath($target);
  513. if ($this->is_file($source)) {
  514. if ($this->copy($source, $target) === false) {
  515. return false;
  516. }
  517. if ($this->unlink($source) === false) {
  518. $this->unlink($target);
  519. return false;
  520. }
  521. } else {
  522. if ($this->copy($source, $target) === false) {
  523. return false;
  524. }
  525. if ($this->rmdir($source) === false) {
  526. $this->rmdir($target);
  527. return false;
  528. }
  529. }
  530. return true;
  531. }
  532. public function test() {
  533. $this->getConnection()->headBucket([
  534. 'Bucket' => $this->bucket
  535. ]);
  536. return true;
  537. }
  538. public function getId() {
  539. return $this->id;
  540. }
  541. public function writeBack($tmpFile, $path) {
  542. try {
  543. $source = fopen($tmpFile, 'r');
  544. $this->writeObject($path, $source, $this->mimeDetector->detectPath($path));
  545. $this->invalidateCache($path);
  546. unlink($tmpFile);
  547. return true;
  548. } catch (S3Exception $e) {
  549. $this->logger->error($e->getMessage(), [
  550. 'app' => 'files_external',
  551. 'exception' => $e,
  552. ]);
  553. return false;
  554. }
  555. }
  556. /**
  557. * check if curl is installed
  558. */
  559. public static function checkDependencies() {
  560. return true;
  561. }
  562. public function getDirectoryContent($directory): \Traversable {
  563. $path = $this->normalizePath($directory);
  564. if ($this->isRoot($path)) {
  565. $path = '';
  566. } else {
  567. $path .= '/';
  568. }
  569. $results = $this->getConnection()->getPaginator('ListObjectsV2', [
  570. 'Bucket' => $this->bucket,
  571. 'Delimiter' => '/',
  572. 'Prefix' => $path,
  573. ]);
  574. foreach ($results as $result) {
  575. // sub folders
  576. if (is_array($result['CommonPrefixes'])) {
  577. foreach ($result['CommonPrefixes'] as $prefix) {
  578. $dir = $this->getDirectoryMetaData($prefix['Prefix']);
  579. if ($dir) {
  580. yield $dir;
  581. }
  582. }
  583. }
  584. if (is_array($result['Contents'])) {
  585. foreach ($result['Contents'] as $object) {
  586. $this->objectCache[$object['Key']] = $object;
  587. if ($object['Key'] !== $path) {
  588. yield $this->objectToMetaData($object);
  589. }
  590. }
  591. }
  592. }
  593. }
  594. private function objectToMetaData(array $object): array {
  595. return [
  596. 'name' => basename($object['Key']),
  597. 'mimetype' => $this->mimeDetector->detectPath($object['Key']),
  598. 'mtime' => strtotime($object['LastModified']),
  599. 'storage_mtime' => strtotime($object['LastModified']),
  600. 'etag' => trim($object['ETag'], '"'),
  601. 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE,
  602. 'size' => (int)($object['Size'] ?? $object['ContentLength']),
  603. ];
  604. }
  605. private function getDirectoryMetaData(string $path): ?array {
  606. $path = trim($path, '/');
  607. // when versioning is enabled, delete markers are returned as part of CommonPrefixes
  608. // resulting in "ghost" folders, verify that each folder actually exists
  609. if ($this->versioningEnabled() && !$this->doesDirectoryExist($path)) {
  610. return null;
  611. }
  612. $cacheEntry = $this->getCache()->get($path);
  613. if ($cacheEntry instanceof CacheEntry) {
  614. return $cacheEntry->getData();
  615. } else {
  616. return [
  617. 'name' => basename($path),
  618. 'mimetype' => FileInfo::MIMETYPE_FOLDER,
  619. 'mtime' => time(),
  620. 'storage_mtime' => time(),
  621. 'etag' => uniqid(),
  622. 'permissions' => Constants::PERMISSION_ALL,
  623. 'size' => -1,
  624. ];
  625. }
  626. }
  627. public function versioningEnabled(): bool {
  628. if ($this->versioningEnabled === null) {
  629. $cached = $this->memCache->get('versioning-enabled::' . $this->getBucket());
  630. if ($cached === null) {
  631. $this->versioningEnabled = $this->getVersioningStatusFromBucket();
  632. $this->memCache->set('versioning-enabled::' . $this->getBucket(), $this->versioningEnabled, 60);
  633. } else {
  634. $this->versioningEnabled = $cached;
  635. }
  636. }
  637. return $this->versioningEnabled;
  638. }
  639. protected function getVersioningStatusFromBucket(): bool {
  640. try {
  641. $result = $this->getConnection()->getBucketVersioning(['Bucket' => $this->getBucket()]);
  642. return $result->get('Status') === 'Enabled';
  643. } catch (S3Exception $s3Exception) {
  644. // This is needed for compatibility with Storj gateway which does not support versioning yet
  645. if ($s3Exception->getAwsErrorCode() === 'NotImplemented' || $s3Exception->getAwsErrorCode() === 'AccessDenied') {
  646. return false;
  647. }
  648. throw $s3Exception;
  649. }
  650. }
  651. public function hasUpdated($path, $time) {
  652. // for files we can get the proper mtime
  653. if ($path !== '' && $object = $this->headObject($path)) {
  654. $stat = $this->objectToMetaData($object);
  655. return $stat['mtime'] > $time;
  656. } else {
  657. // for directories, the only real option we have is to do a prefix listing and iterate over all objects
  658. // however, since this is just as expensive as just re-scanning the directory, we can simply return true
  659. // and have the scanner figure out if anything has actually changed
  660. return true;
  661. }
  662. }
  663. public function writeStream(string $path, $stream, ?int $size = null): int {
  664. if ($size === null) {
  665. $size = 0;
  666. // track the number of bytes read from the input stream to return as the number of written bytes.
  667. $stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size) {
  668. $size = $writtenSize;
  669. });
  670. }
  671. if (!is_resource($stream)) {
  672. throw new \InvalidArgumentException('Invalid stream provided');
  673. }
  674. $path = $this->normalizePath($path);
  675. $this->writeObject($path, $stream, $this->mimeDetector->detectPath($path));
  676. $this->invalidateCache($path);
  677. return $size;
  678. }
  679. }