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