├── 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 | [](https://travis-ci.org/eddturtle/direct-upload)
4 | [](https://packagist.org/packages/eddturtle/direct-upload)
5 | [](https://packagist.org/packages/eddturtle/direct-upload)
6 | [](https://packagist.org/packages/eddturtle/direct-upload)
7 | [](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 |
--------------------------------------------------------------------------------