├── src ├── Exceptions │ ├── InvalidOptionException.php │ ├── InvalidAclException.php │ └── InvalidRegionException.php ├── SignatureAuto.php ├── Acl.php ├── Region.php ├── Options.php └── Signature.php ├── composer.json ├── LICENSE └── README.md /src/Exceptions/InvalidOptionException.php: -------------------------------------------------------------------------------- 1 | =7.2", 23 | "ext-json": "*" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^8.0||^9.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "EddTurtle\\DirectUpload\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Eddturtle\\DirectUpload\\Tests\\": "tests/" 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Edd Turtle 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Acl.php: -------------------------------------------------------------------------------- 1 | setName($acl); 37 | } 38 | 39 | /** 40 | * @return string the aws acl policy name. 41 | */ 42 | public function __toString() 43 | { 44 | return $this->getName(); 45 | } 46 | 47 | /** 48 | * @param string $acl the aws acl policy name. 49 | * 50 | * @throws InvalidAclException 51 | */ 52 | public function setName(string $acl): void 53 | { 54 | $acl = strtolower($acl); 55 | if (!in_array($acl, $this->possibleOptions)) { 56 | throw new InvalidAclException; 57 | } 58 | $this->name = $acl; 59 | } 60 | 61 | /** 62 | * @return string the aws acl policy name. 63 | */ 64 | public function getName(): string 65 | { 66 | return $this->name; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Region.php: -------------------------------------------------------------------------------- 1 | setName($region); 58 | } 59 | 60 | /** 61 | * @return string the aws region. 62 | */ 63 | public function __toString() 64 | { 65 | return $this->getName(); 66 | } 67 | 68 | /** 69 | * @param string $region the aws region. 70 | * 71 | * @throws InvalidRegionException 72 | */ 73 | public function setName(string $region): void 74 | { 75 | $region = strtolower($region); 76 | if (!in_array($region, $this->possibleOptions)) { 77 | throw new InvalidRegionException; 78 | } 79 | $this->name = $region; 80 | } 81 | 82 | /** 83 | * @return string the aws region. 84 | */ 85 | public function getName(): string 86 | { 87 | return $this->name; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | 201, 27 | 28 | // If the file should be private/public-read/public-write. 29 | // This is file specific, not bucket. More info: http://amzn.to/1SSOgwO 30 | 'acl' => 'private', 31 | 32 | // The file's name on s3, can be set with JS by changing the input[name="key"]. 33 | // ${filename} will just mean the original filename of the file being uploaded. 34 | 'default_filename' => '${filename}', 35 | 36 | // The maximum file size of an upload in MB. Will refuse with a EntityTooLarge 37 | // and 400 Bad Request if you exceed this limit. 38 | 'max_file_size' => 500, 39 | 40 | // Request expiration time, specified in relative time format or in seconds. 41 | // minimum of 1 (+1 second), maximum of 604800 (+7 days) 42 | 'expires' => '+6 hours', 43 | 44 | // Server will check that the filename starts with this prefix and fail 45 | // with a AccessDenied 403 if not. 46 | 'valid_prefix' => '', 47 | 48 | // Strictly only allow a single content type, blank will allow all. Will fail 49 | // with a AccessDenied 403 is this condition is not met. 50 | 'content_type' => '', 51 | 52 | // Sets whether AWS server side encryption should be applied to the uploaded files, 53 | // so that files will be encrypted with AES256 when at rest. 54 | 'encryption' => false, 55 | 56 | // Allow S3 compatible solutions by specifying the domain it should POST to. Must be 57 | // a valid url (inc. http/https) otherwise will throw InvalidOptionException. 58 | 'custom_url' => null, 59 | 60 | // Set Amazon S3 Transfer Acceleration 61 | 'accelerate' => false, 62 | 63 | // Any additional inputs to add to the form. This is an array of name => value 64 | // pairs e.g. ['Content-Disposition' => 'attachment'] 65 | 'additional_inputs' => [] 66 | 67 | ]; 68 | 69 | public function __construct(array $options = []) 70 | { 71 | $this->setOptions($options); 72 | } 73 | 74 | /** 75 | * Get all options. 76 | * 77 | * @return array 78 | */ 79 | public function getOptions(): array 80 | { 81 | return $this->options; 82 | } 83 | 84 | public function get(string $name) 85 | { 86 | if (!array_key_exists($name, $this->options)) { 87 | throw new InvalidOptionException("Invalid option given to get()"); 88 | } 89 | return $this->options[$name]; 90 | } 91 | 92 | /** 93 | * Set/overwrite any default options. 94 | * 95 | * @param array $options any options to override. 96 | */ 97 | public function setOptions(array $options): void 98 | { 99 | // Overwrite default options 100 | $this->options = $options + $this->options; 101 | 102 | $this->options['acl'] = new Acl($this->options['acl']); 103 | 104 | // Return HTTP code must be a string 105 | $this->options['success_status'] = (string)$this->options['success_status']; 106 | 107 | // Encryption option is just a helper to set this header, but we need to set it early on so it 108 | // affects both the policy and the inputs generated. 109 | if ($this->options['encryption']) { 110 | $this->options['additional_inputs']['X-amz-server-side-encryption'] = 'AES256'; 111 | } 112 | } 113 | 114 | public function set(string $name, $value): void 115 | { 116 | if (!array_key_exists($name, $this->options)) { 117 | throw new InvalidOptionException("Invalid option given to set()"); 118 | } 119 | $this->options[$name] = $value; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Direct Upload to S3 (using PHP) 2 | 3 | [![Build Status](https://travis-ci.org/eddturtle/direct-upload.svg?branch=master)](https://travis-ci.org/eddturtle/direct-upload) 4 | [![Latest Stable Version](https://poser.pugx.org/eddturtle/direct-upload/v/stable)](https://packagist.org/packages/eddturtle/direct-upload) 5 | [![Total Downloads](https://poser.pugx.org/eddturtle/direct-upload/downloads)](https://packagist.org/packages/eddturtle/direct-upload) 6 | [![License](https://poser.pugx.org/eddturtle/direct-upload/license)](https://packagist.org/packages/eddturtle/direct-upload) 7 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/eddturtle/direct-upload/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/eddturtle/direct-upload) 8 | 9 | This package is designed to build the necessary AWS signature (v4), policy and form inputs for sending files directly to Amazon's S3 service. This is especially useful when uploading from cloud platforms and help to build '[twelve factor apps](http://12factor.net/backing-services)'. 10 | 11 | This project was sprouted from [this blog post](https://www.designedbyaturtle.co.uk/2015/direct-upload-to-s3-using-aws-signature-v4-php/) which might help explain how the code works and how to set it up. The blog post also has lots of useful comments, which might help you out if you're having problems. 12 | 13 | Supports PHP 7.2+ (if you need php 5.5+ use v1.*) 14 | 15 | ### Install 16 | 17 | This package can be installed using Composer by running: 18 | 19 | composer require eddturtle/direct-upload 20 | 21 | ### Usage 22 | 23 | Once we have the package installed we can make our uploader object like so: (remember to add your S3 details) 24 | 25 | Option 1: Specify AWS Credentials 26 | 27 | ```php 28 | getFormUrl(); ?>" method="POST" enctype="multipart/form-data"> 62 | 63 | getFormInputsAsHtml(); ?> 64 | 65 | 66 | 67 | ``` 68 | 69 | ### Example 70 | 71 | We have an [example project](https://github.com/eddturtle/direct-upload-s3-signaturev4) setup, along with the JavaScript, to demonstrate how the whole process will work. 72 | 73 | ### S3 CORS Configuration 74 | 75 | When uploading a file to S3 through the browser it's important that the bucket has a [CORS configuration](http://docs.aws.amazon.com/AmazonS3/latest/dev/cors.html) that's open to accepting files from elsewhere. Here's an example CORS setup: 76 | 77 | ```xml 78 | 79 | 80 | 81 | * 82 | GET 83 | POST 84 | PUT 85 | 3000 86 | * 87 | 88 | 89 | ``` 90 | 91 | ### Options 92 | 93 | Options can be passed into the Signature class as a fifth parameter, below is a list of possible options which can be overwritten. 94 | 95 | | Option | Default | Description | 96 | | ----------------- | ----------- |------------- | 97 | | success_status | 201 | If the upload is a success, this is the http code we get back from S3. By default this will be a 201 Created. | 98 | | acl | private | If the file should be private/public-read/public-write. This is file specific, not bucket. More info: http://amzn.to/1SSOgwO | 99 | | default_filename | ${filename} | The file's name on s3, can be set with JS by changing the input[name="key"]. ${filename} will just mean the original filename of the file being uploaded. | 100 | | max_file_size | 500 | The maximum file size of an upload in MB. Will refuse with a EntityTooLarge and 400 Bad Request if you exceed this limit. | 101 | | expires | +6 hours | Request expiration time, specified in relative time format or in seconds. min: 1 (+1 second), max: 604800 (+7 days) | 102 | | valid_prefix | | Server will check that the filename starts with this prefix and fail with a AccessDenied 403 if not. | 103 | | content_type | | Strictly only allow a single content type, blank will allow all. Will fail with a AccessDenied 403 is this condition is not met. | 104 | | encryption | false | Sets whether AWS server side encryption should be applied to the uploaded files, so that files will be encrypted with AES256 when at rest. Should be a true or false bool. | 105 | | custom_url | null | Allow S3 compatible solutions by specifying the domain it should POST to. Must be a valid url (inc. http/https) otherwise will throw InvalidOptionException. | 106 | | accelerate | false | Set Amazon S3 Transfer Acceleration - more info @ [http://amzn.to/2xKblKe](http://amzn.to/2xKblKe). Should be a true or false bool. | 107 | | additional_inputs | | Any additional inputs to add to the form. This is an array of name => value pairs e.g. ['Content-Disposition' => 'attachment'] | 108 | 109 | For example: 110 | 111 | ```php 112 | $uploader = new SignatureAuto("", "", [ 113 | 'acl' => 'public-read', 114 | 'max_file_size' => 10, 115 | 'encryption' => true, 116 | 'additional_inputs' => [ 117 | 'Content-Disposition' => 'attachment' 118 | ] 119 | ]); 120 | ``` 121 | 122 | ### Available Signature Methods 123 | 124 | | Method | Description | 125 | | --------------------- | ------------ | 126 | | getFormUrl() | Gets the submission url to go into your form's action attribute (will work on http and https). This is useful for getting the right region and url structure. | 127 | | getOptions() | Gets all the options which are currently set. If no options have been changed, this will return the default set of options. | 128 | | setOptions() | Change any options after the signature has been instantiated. | 129 | | getSignature() | Get the AWS Signature (v4), won't be needed if you're using getFormInputs() or getFormInputsAsHtml() - but useful if you are building your own form html and just need the signature. | 130 | | getFormInputs() | Returns an array of all the inputs you'll need to submit in your form. This has an option parameter if the input[type="key"] is wanted (defaults to true). | 131 | | getFormInputsAsHtml() | Uses getFormInputs() to build the required html to go into your form. | 132 | 133 | ### Contributing 134 | 135 | Contributions via pull requests are welcome. The project is built with [PSR 1+2 coding standards](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md), if any code is submitted it should adhere to this and come with any applicable tests for code changed/added. Where possible also keep one pull request per feature. 136 | 137 | Running the tests is as easy as running: 138 | 139 | vendor/bin/phpunit 140 | 141 | ### Licence 142 | 143 | This project is licenced under the MIT licence, which you can view in full within the LICENCE file of this repository. 144 | -------------------------------------------------------------------------------- /src/Signature.php: -------------------------------------------------------------------------------- 1 | setAwsCredentials($key, $secret); 64 | $this->time = time(); 65 | 66 | $this->bucket = $bucket; 67 | $this->region = new Region($region); 68 | 69 | $this->options = new Options($options); 70 | } 71 | 72 | /** 73 | * Set the AWS Credentials 74 | * 75 | * @param string $key the AWS API Key to use. 76 | * @param string $secret the AWS API Secret to use. 77 | */ 78 | protected function setAwsCredentials(string $key, string $secret): void 79 | { 80 | // Key 81 | if (empty($key)) { 82 | throw new \InvalidArgumentException("Empty AWS Key Provided"); 83 | } 84 | if ($key === "YOUR_S3_KEY") { 85 | throw new \InvalidArgumentException("Invalid AWS Key Provided"); 86 | } 87 | $this->key = $key; 88 | 89 | // Secret 90 | if (empty($secret)) { 91 | throw new \InvalidArgumentException("Empty AWS Secret Provided"); 92 | } 93 | if ($secret === "YOUR_S3_SECRET") { 94 | throw new \InvalidArgumentException("Invalid AWS Secret Provided"); 95 | } 96 | $this->secret = $secret; 97 | } 98 | 99 | /** 100 | * Build the form url for sending files, this will include the bucket and the region name. 101 | * 102 | * @return string the s3 bucket's url. 103 | */ 104 | public function getFormUrl(): string 105 | { 106 | if (!is_null($this->options->get('custom_url'))) { 107 | return $this->buildCustomUrl(); 108 | } else { 109 | return $this->buildAmazonUrl(); 110 | } 111 | } 112 | 113 | private function buildCustomUrl(): string 114 | { 115 | $url = trim($this->options->get('custom_url')); 116 | 117 | if (filter_var($url, FILTER_VALIDATE_URL) === false) { 118 | throw new InvalidOptionException("The custom_url option you have specified is invalid"); 119 | } 120 | 121 | $separator = (substr($url, -1) === "/" ? "" : "/"); 122 | 123 | return $url . $separator . urlencode($this->bucket); 124 | } 125 | 126 | private function buildAmazonUrl(): string 127 | { 128 | $region = (string)$this->region; 129 | 130 | // Only the us-east-1 region is exempt from needing the region in the url. 131 | if ($region !== "us-east-1") { 132 | $middle = "." . $region; 133 | } else { 134 | $middle = ""; 135 | } 136 | 137 | if ($this->options->get('accelerate')) { 138 | return "//" . urlencode($this->bucket) . "." . self::SERVICE . "-accelerate.amazonaws.com"; 139 | } else { 140 | return "//" . self::SERVICE . $middle . ".amazonaws.com" . "/" . urlencode($this->bucket); 141 | } 142 | } 143 | 144 | /** 145 | * Get all options. 146 | * 147 | * @return array 148 | */ 149 | public function getOptions(): array 150 | { 151 | return $this->options->getOptions(); 152 | } 153 | 154 | /** 155 | * Edit/Update a new list of options. 156 | * 157 | * @param array $options a list of options to update. 158 | * 159 | * @return void 160 | */ 161 | public function setOptions(array $options): void 162 | { 163 | $this->options->setOptions($options); 164 | } 165 | 166 | /** 167 | * Get an AWS Signature V4 generated. 168 | * 169 | * @return string the aws v4 signature. 170 | */ 171 | public function getSignature(): string 172 | { 173 | if (is_null($this->signature)) { 174 | $this->generateScope(); 175 | $this->generatePolicy(); 176 | $this->generateSignature(); 177 | } 178 | return $this->signature; 179 | } 180 | 181 | /** 182 | * Generate the necessary hidden inputs to go within the form. These inputs should match what's being send in 183 | * the policy. 184 | * 185 | * @param bool $addKey whether to add the 'key' input (filename), defaults to yes. 186 | * 187 | * @return array of the form inputs. 188 | */ 189 | public function getFormInputs($addKey = true): array 190 | { 191 | $this->getSignature(); 192 | 193 | $inputs = [ 194 | 'Content-Type' => $this->options->get('content_type'), 195 | 'acl' => (string)$this->options->get('acl'), 196 | 'success_action_status' => $this->options->get('success_status'), 197 | 'policy' => $this->base64Policy, 198 | 'X-amz-credential' => $this->credentials, 199 | 'X-amz-algorithm' => self::ALGORITHM, 200 | 'X-amz-date' => $this->getFullDateFormat(), 201 | 'X-amz-signature' => $this->signature 202 | ]; 203 | 204 | $inputs = array_merge($inputs, $this->options->get('additional_inputs')); 205 | 206 | if ($addKey) { 207 | // Note: The Key (filename) will need to be populated with JS on upload 208 | // if anything other than the filename is wanted. 209 | $inputs['key'] = $this->options->get('valid_prefix') . $this->options->get('default_filename'); 210 | } 211 | 212 | return $inputs; 213 | } 214 | 215 | /** 216 | * Based on getFormInputs(), this will build up the html to go within the form. 217 | * 218 | * @param bool $addKey whether to add the 'key' input (filename), defaults to yes. 219 | * 220 | * @return string html of hidden form inputs. 221 | */ 222 | public function getFormInputsAsHtml($addKey = true): string 223 | { 224 | $inputs = []; 225 | foreach ($this->getFormInputs($addKey) as $name => $value) { 226 | $inputs[] = ''; 227 | } 228 | return implode(PHP_EOL, $inputs); 229 | } 230 | 231 | 232 | // Where the magic begins ;) 233 | 234 | /** 235 | * Step 1: Generate the Scope 236 | */ 237 | protected function generateScope(): void 238 | { 239 | $scope = [ 240 | $this->key, 241 | $this->getShortDateFormat(), 242 | $this->region, 243 | self::SERVICE, 244 | self::REQUEST_TYPE 245 | ]; 246 | $this->credentials = implode('/', $scope); 247 | } 248 | 249 | /** 250 | * Step 2: Generate a Base64 Policy 251 | */ 252 | protected function generatePolicy(): void 253 | { 254 | $policy = [ 255 | 'expiration' => $this->getExpirationDate(), 256 | 'conditions' => [ 257 | ['bucket' => $this->bucket], 258 | ['acl' => (string)$this->options->get('acl')], 259 | ['starts-with', '$key', $this->options->get('valid_prefix')], 260 | $this->getPolicyContentTypeArray(), 261 | ['content-length-range', 0, $this->mbToBytes($this->options->get('max_file_size'))], 262 | ['success_action_status' => $this->options->get('success_status')], 263 | ['x-amz-credential' => $this->credentials], 264 | ['x-amz-algorithm' => self::ALGORITHM], 265 | ['x-amz-date' => $this->getFullDateFormat()] 266 | ] 267 | ]; 268 | $policy = $this->addAdditionalInputs($policy); 269 | $this->base64Policy = base64_encode(json_encode($policy)); 270 | } 271 | 272 | private function getPolicyContentTypeArray(): array 273 | { 274 | $contentTypePrefix = (empty($this->options->get('content_type')) ? 'starts-with' : 'eq'); 275 | return [ 276 | $contentTypePrefix, 277 | '$Content-Type', 278 | $this->options->get('content_type') 279 | ]; 280 | } 281 | 282 | private function addAdditionalInputs($policy): array 283 | { 284 | foreach ($this->options->get('additional_inputs') as $name => $value) { 285 | $policy['conditions'][] = ['starts-with', '$' . $name, $value]; 286 | } 287 | return $policy; 288 | } 289 | 290 | /** 291 | * Step 3: Generate and sign the Signature (v4) 292 | */ 293 | protected function generateSignature(): void 294 | { 295 | $signatureData = [ 296 | $this->getShortDateFormat(), 297 | (string)$this->region, 298 | self::SERVICE, 299 | self::REQUEST_TYPE 300 | ]; 301 | 302 | // Iterates over the data (defined in the array above), hashing it each time. 303 | $initial = 'AWS4' . $this->secret; 304 | $signingKey = array_reduce($signatureData, function ($key, $data) { 305 | return $this->keyHash($data, $key); 306 | }, $initial); 307 | 308 | // Finally, use the signing key to hash the policy. 309 | $this->signature = $this->keyHash($this->base64Policy, $signingKey, false); 310 | } 311 | 312 | 313 | // Helper functions 314 | 315 | private function keyHash($data, $key, $raw = true): string 316 | { 317 | return hash_hmac('sha256', $data, $key, $raw); 318 | } 319 | 320 | private function mbToBytes($megaByte): int 321 | { 322 | if (is_numeric($megaByte)) { 323 | return $megaByte * pow(1024, 2); 324 | } 325 | return 0; 326 | } 327 | 328 | 329 | // Dates 330 | private function getShortDateFormat(): string 331 | { 332 | return gmdate("Ymd", $this->time); 333 | } 334 | 335 | private function getFullDateFormat(): string 336 | { 337 | return gmdate("Ymd\THis\Z", $this->time); 338 | } 339 | 340 | private function getExpirationDate(): string 341 | { 342 | // Note: using \DateTime::ISO8601 doesn't work :( 343 | 344 | $exp = strtotime($this->options->get('expires'), $this->time); 345 | $diff = $exp - $this->time; 346 | 347 | if (!($diff >= 1 && $diff <= 604800)) { 348 | throw new \InvalidArgumentException("Expiry must be between 1 and 604800"); 349 | } 350 | 351 | return gmdate('Y-m-d\TG:i:s\Z', $exp); 352 | } 353 | } 354 | --------------------------------------------------------------------------------