509 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			509 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| declare(strict_types=1);
 | |
| 
 | |
| namespace League\Flysystem\AwsS3V3;
 | |
| 
 | |
| use Aws\Api\DateTimeResult;
 | |
| use Aws\S3\S3ClientInterface;
 | |
| use DateTimeInterface;
 | |
| use Generator;
 | |
| use League\Flysystem\ChecksumAlgoIsNotSupported;
 | |
| use League\Flysystem\ChecksumProvider;
 | |
| use League\Flysystem\Config;
 | |
| use League\Flysystem\DirectoryAttributes;
 | |
| use League\Flysystem\FileAttributes;
 | |
| use League\Flysystem\FilesystemAdapter;
 | |
| use League\Flysystem\FilesystemOperationFailed;
 | |
| use League\Flysystem\PathPrefixer;
 | |
| use League\Flysystem\StorageAttributes;
 | |
| use League\Flysystem\UnableToCheckDirectoryExistence;
 | |
| use League\Flysystem\UnableToCheckFileExistence;
 | |
| use League\Flysystem\UnableToCopyFile;
 | |
| use League\Flysystem\UnableToDeleteDirectory;
 | |
| use League\Flysystem\UnableToDeleteFile;
 | |
| use League\Flysystem\UnableToGeneratePublicUrl;
 | |
| use League\Flysystem\UnableToGenerateTemporaryUrl;
 | |
| use League\Flysystem\UnableToMoveFile;
 | |
| use League\Flysystem\UnableToProvideChecksum;
 | |
| use League\Flysystem\UnableToReadFile;
 | |
| use League\Flysystem\UnableToRetrieveMetadata;
 | |
| use League\Flysystem\UnableToSetVisibility;
 | |
| use League\Flysystem\UnableToWriteFile;
 | |
| use League\Flysystem\UrlGeneration\PublicUrlGenerator;
 | |
| use League\Flysystem\UrlGeneration\TemporaryUrlGenerator;
 | |
| use League\Flysystem\Visibility;
 | |
| use League\MimeTypeDetection\FinfoMimeTypeDetector;
 | |
| use League\MimeTypeDetection\MimeTypeDetector;
 | |
| use Psr\Http\Message\StreamInterface;
 | |
| use Throwable;
 | |
| use function trim;
 | |
| 
 | |
| class AwsS3V3Adapter implements FilesystemAdapter, PublicUrlGenerator, ChecksumProvider, TemporaryUrlGenerator
 | |
| {
 | |
|     /**
 | |
|      * @var string[]
 | |
|      */
 | |
|     public const AVAILABLE_OPTIONS = [
 | |
|         'ACL',
 | |
|         'CacheControl',
 | |
|         'ContentDisposition',
 | |
|         'ContentEncoding',
 | |
|         'ContentLength',
 | |
|         'ContentType',
 | |
|         'ContentMD5',
 | |
|         'Expires',
 | |
|         'GrantFullControl',
 | |
|         'GrantRead',
 | |
|         'GrantReadACP',
 | |
|         'GrantWriteACP',
 | |
|         'Metadata',
 | |
|         'MetadataDirective',
 | |
|         'RequestPayer',
 | |
|         'SSECustomerAlgorithm',
 | |
|         'SSECustomerKey',
 | |
|         'SSECustomerKeyMD5',
 | |
|         'SSEKMSKeyId',
 | |
|         'ServerSideEncryption',
 | |
|         'StorageClass',
 | |
|         'Tagging',
 | |
|         'WebsiteRedirectLocation',
 | |
|         'ChecksumAlgorithm',
 | |
|     ];
 | |
|     /**
 | |
|      * @var string[]
 | |
|      */
 | |
|     public const MUP_AVAILABLE_OPTIONS = [
 | |
|         'before_upload',
 | |
|         'concurrency',
 | |
|         'mup_threshold',
 | |
|         'params',
 | |
|         'part_size',
 | |
|     ];
 | |
| 
 | |
|     /**
 | |
|      * @var string[]
 | |
|      */
 | |
|     private const EXTRA_METADATA_FIELDS = [
 | |
|         'Metadata',
 | |
|         'StorageClass',
 | |
|         'ETag',
 | |
|         'VersionId',
 | |
|     ];
 | |
| 
 | |
|     private PathPrefixer $prefixer;
 | |
|     private VisibilityConverter $visibility;
 | |
|     private MimeTypeDetector $mimeTypeDetector;
 | |
| 
 | |
|     public function __construct(
 | |
|         private S3ClientInterface $client,
 | |
|         private string $bucket,
 | |
|         string $prefix = '',
 | |
|         VisibilityConverter $visibility = null,
 | |
|         MimeTypeDetector $mimeTypeDetector = null,
 | |
|         private array $options = [],
 | |
|         private bool $streamReads = true,
 | |
|         private array $forwardedOptions = self::AVAILABLE_OPTIONS,
 | |
|         private array $metadataFields = self::EXTRA_METADATA_FIELDS,
 | |
|         private array $multipartUploadOptions = self::MUP_AVAILABLE_OPTIONS,
 | |
|     ) {
 | |
|         $this->prefixer = new PathPrefixer($prefix);
 | |
|         $this->visibility = $visibility ?: new PortableVisibilityConverter();
 | |
|         $this->mimeTypeDetector = $mimeTypeDetector ?: new FinfoMimeTypeDetector();
 | |
|     }
 | |
| 
 | |
|     public function fileExists(string $path): bool
 | |
|     {
 | |
|         try {
 | |
|             return $this->client->doesObjectExistV2($this->bucket, $this->prefixer->prefixPath($path), false, $this->options);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToCheckFileExistence::forLocation($path, $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function directoryExists(string $path): bool
 | |
|     {
 | |
|         try {
 | |
|             $prefix = $this->prefixer->prefixDirectoryPath($path);
 | |
|             $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix, 'MaxKeys' => 1, 'Delimiter' => '/'];
 | |
|             $command = $this->client->getCommand('ListObjectsV2', $options);
 | |
|             $result = $this->client->execute($command);
 | |
| 
 | |
|             return $result->hasKey('Contents') || $result->hasKey('CommonPrefixes');
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToCheckDirectoryExistence::forLocation($path, $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function write(string $path, string $contents, Config $config): void
 | |
|     {
 | |
|         $this->upload($path, $contents, $config);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * @param string          $path
 | |
|      * @param string|resource $body
 | |
|      * @param Config          $config
 | |
|      */
 | |
|     private function upload(string $path, $body, Config $config): void
 | |
|     {
 | |
|         $key = $this->prefixer->prefixPath($path);
 | |
|         $options = $this->createOptionsFromConfig($config);
 | |
|         $acl = $options['params']['ACL'] ?? $this->determineAcl($config);
 | |
|         $shouldDetermineMimetype = ! array_key_exists('ContentType', $options['params']);
 | |
| 
 | |
|         if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) {
 | |
|             $options['params']['ContentType'] = $mimeType;
 | |
|         }
 | |
| 
 | |
|         try {
 | |
|             $this->client->upload($this->bucket, $key, $body, $acl, $options);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function determineAcl(Config $config): string
 | |
|     {
 | |
|         $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE);
 | |
| 
 | |
|         return $this->visibility->visibilityToAcl($visibility);
 | |
|     }
 | |
| 
 | |
|     private function createOptionsFromConfig(Config $config): array
 | |
|     {
 | |
|         $config = $config->withDefaults($this->options);
 | |
|         $options = ['params' => []];
 | |
| 
 | |
|         if ($mimetype = $config->get('mimetype')) {
 | |
|             $options['params']['ContentType'] = $mimetype;
 | |
|         }
 | |
| 
 | |
|         foreach ($this->forwardedOptions as $option) {
 | |
|             $value = $config->get($option, '__NOT_SET__');
 | |
| 
 | |
|             if ($value !== '__NOT_SET__') {
 | |
|                 $options['params'][$option] = $value;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         foreach ($this->multipartUploadOptions as $option) {
 | |
|             $value = $config->get($option, '__NOT_SET__');
 | |
| 
 | |
|             if ($value !== '__NOT_SET__') {
 | |
|                 $options[$option] = $value;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $options;
 | |
|     }
 | |
| 
 | |
|     public function writeStream(string $path, $contents, Config $config): void
 | |
|     {
 | |
|         $this->upload($path, $contents, $config);
 | |
|     }
 | |
| 
 | |
|     public function read(string $path): string
 | |
|     {
 | |
|         $body = $this->readObject($path, false);
 | |
| 
 | |
|         return (string) $body->getContents();
 | |
|     }
 | |
| 
 | |
|     public function readStream(string $path)
 | |
|     {
 | |
|         /** @var resource $resource */
 | |
|         $resource = $this->readObject($path, true)->detach();
 | |
| 
 | |
|         return $resource;
 | |
|     }
 | |
| 
 | |
|     public function delete(string $path): void
 | |
|     {
 | |
|         $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
 | |
|         $command = $this->client->getCommand('DeleteObject', $arguments);
 | |
| 
 | |
|         try {
 | |
|             $this->client->execute($command);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToDeleteFile::atLocation($path, '', $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function deleteDirectory(string $path): void
 | |
|     {
 | |
|         $prefix = $this->prefixer->prefixPath($path);
 | |
|         $prefix = ltrim(rtrim($prefix, '/') . '/', '/');
 | |
| 
 | |
|         try {
 | |
|             $this->client->deleteMatchingObjects($this->bucket, $prefix);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToDeleteDirectory::atLocation($path, '', $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function createDirectory(string $path, Config $config): void
 | |
|     {
 | |
|         $defaultVisibility = $config->get('directory_visibility', $this->visibility->defaultForDirectories());
 | |
|         $config = $config->withDefaults(['visibility' => $defaultVisibility]);
 | |
|         $this->upload(rtrim($path, '/') . '/', '', $config);
 | |
|     }
 | |
| 
 | |
|     public function setVisibility(string $path, string $visibility): void
 | |
|     {
 | |
|         $arguments = [
 | |
|             'Bucket' => $this->bucket,
 | |
|             'Key' => $this->prefixer->prefixPath($path),
 | |
|             'ACL' => $this->visibility->visibilityToAcl($visibility),
 | |
|         ];
 | |
|         $command = $this->client->getCommand('PutObjectAcl', $arguments);
 | |
| 
 | |
|         try {
 | |
|             $this->client->execute($command);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToSetVisibility::atLocation($path, '', $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function visibility(string $path): FileAttributes
 | |
|     {
 | |
|         $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
 | |
|         $command = $this->client->getCommand('GetObjectAcl', $arguments);
 | |
| 
 | |
|         try {
 | |
|             $result = $this->client->execute($command);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToRetrieveMetadata::visibility($path, '', $exception);
 | |
|         }
 | |
| 
 | |
|         $visibility = $this->visibility->aclToVisibility((array) $result->get('Grants'));
 | |
| 
 | |
|         return new FileAttributes($path, null, $visibility);
 | |
|     }
 | |
| 
 | |
|     private function fetchFileMetadata(string $path, string $type): FileAttributes
 | |
|     {
 | |
|         $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
 | |
|         $command = $this->client->getCommand('HeadObject', $arguments);
 | |
| 
 | |
|         try {
 | |
|             $result = $this->client->execute($command);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToRetrieveMetadata::create($path, $type, '', $exception);
 | |
|         }
 | |
| 
 | |
|         $attributes = $this->mapS3ObjectMetadata($result->toArray(), $path);
 | |
| 
 | |
|         if ( ! $attributes instanceof FileAttributes) {
 | |
|             throw UnableToRetrieveMetadata::create($path, $type, '');
 | |
|         }
 | |
| 
 | |
|         return $attributes;
 | |
|     }
 | |
| 
 | |
|     private function mapS3ObjectMetadata(array $metadata, string $path): StorageAttributes
 | |
|     {
 | |
|         if (substr($path, -1) === '/') {
 | |
|             return new DirectoryAttributes(rtrim($path, '/'));
 | |
|         }
 | |
| 
 | |
|         $mimetype = $metadata['ContentType'] ?? null;
 | |
|         $fileSize = $metadata['ContentLength'] ?? $metadata['Size'] ?? null;
 | |
|         $fileSize = $fileSize === null ? null : (int) $fileSize;
 | |
|         $dateTime = $metadata['LastModified'] ?? null;
 | |
|         $lastModified = $dateTime instanceof DateTimeResult ? $dateTime->getTimeStamp() : null;
 | |
| 
 | |
|         return new FileAttributes(
 | |
|             $path,
 | |
|             $fileSize,
 | |
|             null,
 | |
|             $lastModified,
 | |
|             $mimetype,
 | |
|             $this->extractExtraMetadata($metadata)
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     private function extractExtraMetadata(array $metadata): array
 | |
|     {
 | |
|         $extracted = [];
 | |
| 
 | |
|         foreach ($this->metadataFields as $field) {
 | |
|             if (isset($metadata[$field]) && $metadata[$field] !== '') {
 | |
|                 $extracted[$field] = $metadata[$field];
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return $extracted;
 | |
|     }
 | |
| 
 | |
|     public function mimeType(string $path): FileAttributes
 | |
|     {
 | |
|         $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE);
 | |
| 
 | |
|         if ($attributes->mimeType() === null) {
 | |
|             throw UnableToRetrieveMetadata::mimeType($path);
 | |
|         }
 | |
| 
 | |
|         return $attributes;
 | |
|     }
 | |
| 
 | |
|     public function lastModified(string $path): FileAttributes
 | |
|     {
 | |
|         $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED);
 | |
| 
 | |
|         if ($attributes->lastModified() === null) {
 | |
|             throw UnableToRetrieveMetadata::lastModified($path);
 | |
|         }
 | |
| 
 | |
|         return $attributes;
 | |
|     }
 | |
| 
 | |
|     public function fileSize(string $path): FileAttributes
 | |
|     {
 | |
|         $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE);
 | |
| 
 | |
|         if ($attributes->fileSize() === null) {
 | |
|             throw UnableToRetrieveMetadata::fileSize($path);
 | |
|         }
 | |
| 
 | |
|         return $attributes;
 | |
|     }
 | |
| 
 | |
|     public function listContents(string $path, bool $deep): iterable
 | |
|     {
 | |
|         $prefix = trim($this->prefixer->prefixPath($path), '/');
 | |
|         $prefix = empty($prefix) ? '' : $prefix . '/';
 | |
|         $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix];
 | |
| 
 | |
|         if ($deep === false) {
 | |
|             $options['Delimiter'] = '/';
 | |
|         }
 | |
| 
 | |
|         $listing = $this->retrievePaginatedListing($options);
 | |
| 
 | |
|         foreach ($listing as $item) {
 | |
|             $key = $item['Key'] ?? $item['Prefix'];
 | |
| 
 | |
|             if ($key === $prefix) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             yield $this->mapS3ObjectMetadata($item, $this->prefixer->stripPrefix($key));
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function retrievePaginatedListing(array $options): Generator
 | |
|     {
 | |
|         $resultPaginator = $this->client->getPaginator('ListObjectsV2', $options + $this->options);
 | |
| 
 | |
|         foreach ($resultPaginator as $result) {
 | |
|             yield from ($result->get('CommonPrefixes') ?: []);
 | |
|             yield from ($result->get('Contents') ?: []);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function move(string $source, string $destination, Config $config): void
 | |
|     {
 | |
|         try {
 | |
|             $this->copy($source, $destination, $config);
 | |
|             $this->delete($source);
 | |
|         } catch (FilesystemOperationFailed $exception) {
 | |
|             throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function copy(string $source, string $destination, Config $config): void
 | |
|     {
 | |
|         try {
 | |
|             /** @var string $visibility */
 | |
|             $visibility = $config->get(Config::OPTION_VISIBILITY) ?: $this->visibility($source)->visibility();
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToCopyFile::fromLocationTo(
 | |
|                 $source,
 | |
|                 $destination,
 | |
|                 $exception
 | |
|             );
 | |
|         }
 | |
| 
 | |
|         try {
 | |
|             $this->client->copy(
 | |
|                 $this->bucket,
 | |
|                 $this->prefixer->prefixPath($source),
 | |
|                 $this->bucket,
 | |
|                 $this->prefixer->prefixPath($destination),
 | |
|                 $this->visibility->visibilityToAcl($visibility),
 | |
|                 $this->createOptionsFromConfig($config)['params']
 | |
|             );
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function readObject(string $path, bool $wantsStream): StreamInterface
 | |
|     {
 | |
|         $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
 | |
| 
 | |
|         if ($wantsStream && $this->streamReads && ! isset($this->options['@http']['stream'])) {
 | |
|             $options['@http']['stream'] = true;
 | |
|         }
 | |
| 
 | |
|         $command = $this->client->getCommand('GetObject', $options + $this->options);
 | |
| 
 | |
|         try {
 | |
|             return $this->client->execute($command)->get('Body');
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToReadFile::fromLocation($path, '', $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function publicUrl(string $path, Config $config): string
 | |
|     {
 | |
|         $location = $this->prefixer->prefixPath($path);
 | |
| 
 | |
|         try {
 | |
|             return $this->client->getObjectUrl($this->bucket, $location);
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToGeneratePublicUrl::dueToError($path, $exception);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public function checksum(string $path, Config $config): string
 | |
|     {
 | |
|         $algo = $config->get('checksum_algo', 'etag');
 | |
| 
 | |
|         if ($algo !== 'etag') {
 | |
|             throw new ChecksumAlgoIsNotSupported();
 | |
|         }
 | |
| 
 | |
|         try {
 | |
|             $metadata = $this->fetchFileMetadata($path, 'checksum')->extraMetadata();
 | |
|         } catch (UnableToRetrieveMetadata $exception) {
 | |
|             throw new UnableToProvideChecksum($exception->reason(), $path, $exception);
 | |
|         }
 | |
| 
 | |
|         if ( ! isset($metadata['ETag'])) {
 | |
|             throw new UnableToProvideChecksum('ETag header not available.', $path);
 | |
|         }
 | |
| 
 | |
|         return trim($metadata['ETag'], '"');
 | |
|     }
 | |
| 
 | |
|     public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string
 | |
|     {
 | |
|         try {
 | |
|             $options = $config->get('get_object_options', []);
 | |
|             $command = $this->client->getCommand('GetObject', [
 | |
|                     'Bucket' => $this->bucket,
 | |
|                     'Key' => $this->prefixer->prefixPath($path),
 | |
|                 ] + $options);
 | |
| 
 | |
|             $presignedRequestOptions = $config->get('presigned_request_options', []);
 | |
|             $request = $this->client->createPresignedRequest($command, $expiresAt, $presignedRequestOptions);
 | |
| 
 | |
|             return (string)$request->getUri();
 | |
|         } catch (Throwable $exception) {
 | |
|             throw UnableToGenerateTemporaryUrl::dueToError($path, $exception);
 | |
|         }
 | |
|     }
 | |
| }
 | 
