├── .gitattributes ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src └── SimpleS3Client.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | /.gitignore export-ignore 4 | /phpunit.xml.dist export-ignore 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## NOT RELEASED 4 | 5 | ## 2.1.1 6 | 7 | ### Changed 8 | 9 | - Enable compiler optimization for the `sprintf` function. 10 | 11 | ## 2.1.0 12 | 13 | ### Added 14 | 15 | - Added optional `versionId` parameter to `SimpleS3Client::getPresignedUrl()` 16 | 17 | ## 2.0.0 18 | 19 | ### BC-BREAK 20 | 21 | - Upgrade to `async-aws/s3` 2.0 22 | 23 | ## 1.1.1 24 | 25 | ### Changed 26 | 27 | - Improve parameter type and return type in phpdoc 28 | 29 | ## 1.1.0 30 | 31 | ### Added 32 | 33 | - Added `SimpleS3Client::getPresignedUrl()` 34 | 35 | ## 1.0.0 36 | 37 | - Empty release 38 | 39 | ## 0.1.2 40 | 41 | ### Added 42 | 43 | - Support for PHP 8 44 | 45 | ## 0.1.1 46 | 47 | ### Added 48 | 49 | - `SimpleS3Client::download()`, `SimpleS3Client::has()` and `SimpleS3Client::upload()` methods. 50 | 51 | ## 0.1.0 52 | 53 | First version 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Jérémy Derussé, Tobias Nyholm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AsyncAws Simple S3 client 2 | 3 | ![](https://github.com/async-aws/simple-s3/workflows/Tests/badge.svg?branch=master) 4 | ![](https://github.com/async-aws/simple-s3/workflows/BC%20Check/badge.svg?branch=master) 5 | 6 | A thin layer on top of the S3 client to make it easier to work with. 7 | 8 | ## Install 9 | 10 | ```cli 11 | composer require async-aws/simple-s3 12 | ``` 13 | 14 | ## Documentation 15 | 16 | See https://async-aws.com/integration/simple-s3.html for documentation. 17 | 18 | ## Contribute 19 | 20 | Contributions are welcome and appreciated. Please read https://async-aws.com/contribute/ 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "async-aws/simple-s3", 3 | "description": "A simple S3 client that are easy to work with. ", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "aws", 8 | "async-aws", 9 | "amazon", 10 | "sdk", 11 | "s3" 12 | ], 13 | "require": { 14 | "php": "^7.2.5 || ^8.0", 15 | "ext-json": "*", 16 | "async-aws/s3": "^2.0" 17 | }, 18 | "autoload": { 19 | "psr-4": { 20 | "AsyncAws\\SimpleS3\\": "src" 21 | } 22 | }, 23 | "autoload-dev": { 24 | "psr-4": { 25 | "AsyncAws\\SimpleS3\\Tests\\": "tests/" 26 | } 27 | }, 28 | "extra": { 29 | "branch-alias": { 30 | "dev-master": "2.1-dev" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/SimpleS3Client.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class SimpleS3Client extends S3Client 22 | { 23 | public function getUrl(string $bucket, string $key): string 24 | { 25 | $uri = \sprintf('/%s/%s', urlencode($bucket), str_replace('%2F', '/', rawurlencode($key))); 26 | 27 | return $this->getEndpoint($uri, [], null); 28 | } 29 | 30 | public function getPresignedUrl( 31 | string $bucket, 32 | string $key, 33 | ?\DateTimeImmutable $expires = null, 34 | ?string $versionId = null 35 | ): string { 36 | $request = new GetObjectRequest([ 37 | 'Bucket' => $bucket, 38 | 'Key' => $key, 39 | ]); 40 | 41 | if (null !== $versionId) { 42 | $request->setVersionId($versionId); 43 | } 44 | 45 | return $this->presign($request, $expires); 46 | } 47 | 48 | public function download(string $bucket, string $key): ResultStream 49 | { 50 | return $this->getObject(['Bucket' => $bucket, 'Key' => $key])->getBody(); 51 | } 52 | 53 | public function has(string $bucket, string $key): bool 54 | { 55 | return $this->objectExists(['Bucket' => $bucket, 'Key' => $key])->isSuccess(); 56 | } 57 | 58 | /** 59 | * @param string|resource|(callable(int): string)|iterable $object 60 | * @param array{ 61 | * ACL?: \AsyncAws\S3\Enum\ObjectCannedACL::*, 62 | * CacheControl?: string, 63 | * ContentLength?: int, 64 | * ContentType?: string, 65 | * Metadata?: array, 66 | * PartSize?: int, 67 | * } $options 68 | */ 69 | public function upload(string $bucket, string $key, $object, array $options = []): void 70 | { 71 | $megabyte = 1024 * 1024; 72 | // Split the stream in 1MB chunk 73 | $stream = $this->getStream($object, 1 * $megabyte); 74 | 75 | if (!empty($options['ContentLength'])) { 76 | $contentLength = (int) $options['ContentLength']; 77 | } else { 78 | $contentLength = $stream->length(); 79 | } 80 | 81 | /* 82 | * The maximum number of parts is 10.000. The partSize must be a power of 2. 83 | * We default this to 64MB per part. That means that we only support to upload 84 | * files smaller than 64 * 10 000 = 640GB. If you are uploading larger files, 85 | * please set PartSize to a higher number, like 128, 256 or 512. (Max 4096). 86 | */ 87 | $partSize = $options['PartSize'] ?? 64; 88 | unset($options['PartSize']); 89 | 90 | // If file is less than 64MB, use normal upload 91 | if (null !== $contentLength && $contentLength < 64 * $megabyte) { 92 | $this->doSmallFileUpload($options, $bucket, $key, $object); 93 | 94 | return; 95 | } 96 | 97 | $parts = []; 98 | $uploadId = ''; 99 | $partNumber = 1; 100 | $chunkIndex = 0; 101 | $buffer = fopen('php://temp', 'rw+'); 102 | foreach ($stream as $chunk) { 103 | // Read chunk to resource 104 | fwrite($buffer, $chunk); 105 | if (++$chunkIndex < $partSize) { 106 | // Continue reading chunk into memory 107 | continue; 108 | } 109 | 110 | // We have a good chunk of data to upload. If this is the first part, then get uploadId. 111 | if (1 === $partNumber) { 112 | /** @var string $uploadId */ 113 | $uploadId = $this->createMultipartUpload(array_merge($options, ['Bucket' => $bucket, 'Key' => $key]))->getUploadId(); 114 | } 115 | 116 | // Start uploading the part. 117 | $parts[] = $this->doMultipartUpload($bucket, $key, $uploadId, $partNumber, $buffer); 118 | ++$partNumber; 119 | $buffer = fopen('php://temp', 'rw+'); 120 | $chunkIndex = 0; 121 | } 122 | 123 | if ($chunkIndex > 0) { 124 | if (empty($uploadId)) { 125 | /* 126 | * The first and only part is too small to upload using MultipartUpload. 127 | * AWS has a limit of minimum 5MB. 128 | * 129 | * Lets use a normal upload. 130 | */ 131 | $this->doSmallFileUpload($options, $bucket, $key, $buffer); 132 | 133 | return; 134 | } 135 | 136 | // upload last chunk 137 | $parts[] = $this->doMultipartUpload($bucket, $key, $uploadId, $partNumber, $buffer); 138 | } 139 | 140 | if (empty($parts)) { 141 | // The upload did not contain any data. Let's create an empty file 142 | $this->doSmallFileUpload($options, $bucket, $key, ''); 143 | 144 | return; 145 | } 146 | 147 | $this->completeMultipartUpload([ 148 | 'Bucket' => $bucket, 149 | 'Key' => $key, 150 | 'UploadId' => $uploadId, 151 | 'MultipartUpload' => new CompletedMultipartUpload(['Parts' => $parts]), 152 | ]); 153 | } 154 | 155 | /** 156 | * @param string|resource|(callable(int): string)|iterable $object 157 | */ 158 | private function getStream($object, int $chunkSize): FixedSizeStream 159 | { 160 | return FixedSizeStream::create( 161 | StreamFactory::create($object, $chunkSize), 162 | $chunkSize 163 | ); 164 | } 165 | 166 | /** 167 | * @param resource $buffer 168 | */ 169 | private function doMultipartUpload(string $bucket, string $key, string $uploadId, int $partNumber, $buffer): CompletedPart 170 | { 171 | try { 172 | $response = $this->uploadPart([ 173 | 'Bucket' => $bucket, 174 | 'Key' => $key, 175 | 'UploadId' => $uploadId, 176 | 'PartNumber' => $partNumber, 177 | 'Body' => $buffer, 178 | ]); 179 | 180 | return new CompletedPart(['ETag' => $response->getETag(), 'PartNumber' => $partNumber]); 181 | } catch (\Throwable $e) { 182 | $this->abortMultipartUpload(['Bucket' => $bucket, 'Key' => $key, 'UploadId' => $uploadId]); 183 | 184 | throw $e; 185 | } 186 | } 187 | 188 | /** 189 | * @param array{ 190 | * ACL?: \AsyncAws\S3\Enum\ObjectCannedACL::*, 191 | * CacheControl?: string, 192 | * ContentLength?: int, 193 | * ContentType?: string, 194 | * Metadata?: array, 195 | * } $options 196 | * @param string|resource|(callable(int): string)|iterable $object 197 | */ 198 | private function doSmallFileUpload(array $options, string $bucket, string $key, $object): void 199 | { 200 | $this->putObject(array_merge($options, [ 201 | 'Bucket' => $bucket, 202 | 'Key' => $key, 203 | 'Body' => $object, 204 | ])); 205 | } 206 | } 207 | --------------------------------------------------------------------------------