├── composer.json ├── LICENSE └── src └── SimpleS3.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mnapoli/simple-s3", 3 | "description": "Simple, single-file and dependency-free AWS S3 client", 4 | "keywords": ["s3", "aws"], 5 | "license": "MIT", 6 | "type": "library", 7 | "autoload": { 8 | "psr-4": { 9 | "Mnapoli\\SimpleS3\\": "src/" 10 | } 11 | }, 12 | "autoload-dev": { 13 | "psr-4": { 14 | "Mnapoli\\SimpleS3\\Test\\": "tests/" 15 | } 16 | }, 17 | "require": { 18 | "php": ">=8.0", 19 | "ext-curl": "*", 20 | "ext-dom": "*", 21 | "ext-iconv": "*", 22 | "ext-zlib": "*" 23 | }, 24 | "require-dev": { 25 | "phpunit/phpunit": "^9", 26 | "mnapoli/hard-mode": "^0.3", 27 | "phpstan/phpstan": "^1", 28 | "symfony/process": "^5|^6", 29 | "symfony/filesystem": "^5|^6" 30 | }, 31 | "config": { 32 | "allow-plugins": { 33 | "dealerdirect/phpcodesniffer-composer-installer": true 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Matthieu Napoli 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 | -------------------------------------------------------------------------------- /src/SimpleS3.php: -------------------------------------------------------------------------------- 1 | } 10 | */ 11 | class SimpleS3 12 | { 13 | private string $accessKeyId; 14 | private string $secretKey; 15 | private ?string $sessionToken; 16 | private string $region; 17 | private ?string $endpoint; 18 | private int $timeoutInSeconds = 5; 19 | 20 | public static function fromEnvironmentVariables(string $region): self 21 | { 22 | return new self( 23 | $_SERVER['AWS_ACCESS_KEY_ID'], 24 | $_SERVER['AWS_SECRET_ACCESS_KEY'], 25 | $_SERVER['AWS_SESSION_TOKEN'], 26 | $region, 27 | ); 28 | } 29 | 30 | public function __construct(string $accessKeyId, string $secretKey, ?string $sessionToken, string $region, ?string $endpoint = null) 31 | { 32 | $this->accessKeyId = $accessKeyId; 33 | $this->secretKey = $secretKey; 34 | $this->sessionToken = $sessionToken; 35 | $this->region = $region; 36 | $this->endpoint = $endpoint; 37 | } 38 | 39 | public function setTimeout(int $timeoutInSeconds): SimpleS3 40 | { 41 | $this->timeoutInSeconds = $timeoutInSeconds; 42 | return $this; 43 | } 44 | 45 | /** 46 | * @param Array $headers 47 | * @return Response 48 | * @throws RuntimeException If the request failed. 49 | */ 50 | public function get(string $bucket, string $key, array $headers = []): array 51 | { 52 | return $this->s3Request('GET', $bucket, $key, $headers); 53 | } 54 | 55 | /** 56 | * `get()` will throw if the object doesn't exist. 57 | * This method will return a 404 status and not throw instead. 58 | * 59 | * @param Array $headers 60 | * @return Response 61 | * @throws RuntimeException If the request failed. 62 | */ 63 | public function getIfExists(string $bucket, string $key, array $headers = []): array 64 | { 65 | return $this->s3Request('GET', $bucket, $key, $headers, '', false); 66 | } 67 | 68 | /** 69 | * @param Array $headers 70 | * @return Response 71 | * @throws RuntimeException If the request failed. 72 | */ 73 | public function put(string $bucket, string $key, string $content, array $headers = []): array 74 | { 75 | return $this->s3Request('PUT', $bucket, $key, $headers, $content); 76 | } 77 | 78 | /** 79 | * @param Array $headers 80 | * @return Response 81 | * @throws RuntimeException If the request failed. 82 | */ 83 | public function delete(string $bucket, string $key, array $headers = []): array 84 | { 85 | return $this->s3Request('DELETE', $bucket, $key, $headers); 86 | } 87 | 88 | /** 89 | * @param Array $headers 90 | * @return Response 91 | * @throws RuntimeException If the request failed. 92 | */ 93 | private function s3Request(string $httpVerb, string $bucket, string $key, array $headers, string $body = '', bool $throwOn404 = true): array 94 | { 95 | $uriPath = str_replace('%2F', '/', rawurlencode($key)); 96 | $uriPath = '/' . ltrim($uriPath, '/'); 97 | $queryString = ''; 98 | $hostname = $this->getHostname($bucket); 99 | $headers['host'] = $hostname; 100 | 101 | // Sign the request via headers 102 | $headers = $this->signRequest($httpVerb, $uriPath, $queryString, $headers, $body); 103 | 104 | if ($this->endpoint) { 105 | $url = $this->endpoint; 106 | } else { 107 | $url = "https://$hostname"; 108 | } 109 | $url = "$url{$uriPath}?$queryString"; 110 | 111 | [$status, $body, $responseHeaders] = $this->curlRequest($httpVerb, $url, $headers, $body); 112 | 113 | $shouldThrow404 = $throwOn404 && ($status === 404); 114 | if ($shouldThrow404 || $status < 200 || ($status >= 400 && $status !== 404)) { 115 | $errorMessage = ''; 116 | if ($body) { 117 | $dom = new DOMDocument; 118 | if (! $dom->loadXML($body)) { 119 | throw new RuntimeException('Could not parse the AWS S3 response: ' . $body); 120 | } 121 | if ($dom->childNodes->item(0)->nodeName === 'Error') { 122 | $errorMessage = $dom->childNodes->item(0)->textContent; 123 | } 124 | } 125 | throw $this->httpError($status, $errorMessage); 126 | } 127 | 128 | return [$status, $body, $responseHeaders]; 129 | } 130 | 131 | /** 132 | * @param Array $headers 133 | * @return Response 134 | * @throws RuntimeException If the request failed. 135 | */ 136 | private function curlRequest(string $httpVerb, string $url, array $headers, string $body): array 137 | { 138 | $curlHeaders = []; 139 | foreach ($headers as $name => $value) { 140 | $curlHeaders[] = "$name: $value"; 141 | } 142 | 143 | $ch = curl_init($url); 144 | if (! $ch) { 145 | throw $this->httpError(null, 'could not create a CURL request for an unknown reason'); 146 | } 147 | 148 | $responseHeadersAsString = ''; 149 | curl_setopt_array($ch, [ 150 | CURLOPT_CUSTOMREQUEST => $httpVerb, 151 | CURLOPT_HTTPHEADER => $curlHeaders, 152 | CURLOPT_FOLLOWLOCATION => true, 153 | CURLOPT_TIMEOUT => $this->timeoutInSeconds, 154 | CURLOPT_POSTFIELDS => $body, 155 | // So that `curl_exec` returns the response body 156 | CURLOPT_RETURNTRANSFER => true, 157 | // Retrieve the response headers 158 | CURLOPT_HEADERFUNCTION => function ($c, $data) use (&$responseHeadersAsString) { 159 | $responseHeadersAsString .= $data; 160 | return strlen($data); 161 | }, 162 | ]); 163 | $responseBody = curl_exec($ch); 164 | $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); 165 | curl_close($ch); 166 | 167 | $success = $status !== 0 && $status !== 100 && $status !== 500 && $status !== 502 && $status !== 503; 168 | if ($responseBody === false || ! $success || curl_errno($ch) > 0) { 169 | throw $this->httpError($status, curl_error($ch)); 170 | } 171 | 172 | $responseHeaders = iconv_mime_decode_headers( 173 | $responseHeadersAsString, 174 | ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 175 | 'UTF-8', 176 | ) ?: []; 177 | 178 | return [$status, (string) $responseBody, $responseHeaders]; 179 | } 180 | 181 | /** 182 | * @param array $headers 183 | * @return array Modified headers 184 | */ 185 | private function signRequest( 186 | string $httpVerb, 187 | string $uriPath, 188 | string $queryString, 189 | array $headers, 190 | string $body 191 | ): array { 192 | $dateAsText = gmdate('Ymd'); 193 | $timeAsText = gmdate('Ymd\THis\Z'); 194 | $scope = "$dateAsText/{$this->region}/s3/aws4_request"; 195 | $bodySignature = hash('sha256', $body); 196 | 197 | $headers['x-amz-date'] = $timeAsText; 198 | $headers['x-amz-content-sha256'] = $bodySignature; 199 | if ($this->sessionToken) { 200 | $headers['x-amz-security-token'] = $this->sessionToken; 201 | } 202 | 203 | // Ensure the headers always have the same order to have a valid AWS signature 204 | $headers = $this->sortHeadersByName($headers); 205 | 206 | // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html 207 | $headerNamesAsString = implode(';', array_map('strtolower', array_keys($headers))); 208 | $headerString = ''; 209 | foreach ($headers as $key => $value) { 210 | $headerString .= strtolower($key) . ':' . trim($value) . "\n"; 211 | } 212 | 213 | $canonicalRequest = "$httpVerb\n$uriPath\n$queryString\n$headerString\n$headerNamesAsString\n$bodySignature"; 214 | 215 | $stringToSign = "AWS4-HMAC-SHA256\n$timeAsText\n$scope\n" . hash('sha256', $canonicalRequest); 216 | $signingKey = hash_hmac( 217 | 'sha256', 218 | 'aws4_request', 219 | hash_hmac( 220 | 'sha256', 221 | 's3', 222 | hash_hmac( 223 | 'sha256', 224 | $this->region, 225 | hash_hmac('sha256', $dateAsText, 'AWS4' . $this->secretKey, true), 226 | true, 227 | ), 228 | true, 229 | ), 230 | true, 231 | ); 232 | $signature = hash_hmac('sha256', $stringToSign, $signingKey); 233 | 234 | $headers['authorization'] = "AWS4-HMAC-SHA256 Credential={$this->accessKeyId}/$scope,SignedHeaders=$headerNamesAsString,Signature=$signature"; 235 | 236 | return $headers; 237 | } 238 | 239 | private function getHostname(string $bucketName): string 240 | { 241 | if ($this->region === 'us-east-1') return "$bucketName.s3.amazonaws.com"; 242 | 243 | return "$bucketName.s3-{$this->region}.amazonaws.com"; 244 | } 245 | 246 | private function httpError(?int $status, ?string $message): RuntimeException 247 | { 248 | return new RuntimeException("AWS S3 request failed: $status $message"); 249 | } 250 | 251 | /** 252 | * @param Array $headers 253 | * @return Array 254 | */ 255 | private function sortHeadersByName(array $headers): array 256 | { 257 | ksort($headers, SORT_STRING | SORT_FLAG_CASE); 258 | return $headers; 259 | } 260 | } 261 | --------------------------------------------------------------------------------