0

Uploading Directly to Amazon S3 with Dropzone and PHP

Posted (Updated ) in Javascript, PHP

Dropzone is a really nice lightweight javascript upload library with drag drop support and a high level of customisability. The documentation for direct uploads to Amazon S3 with a PHP backend are entirely lacking though so I’ve whipped up a complete tutorial on how to make it happen. For this tutorial I’ll be using Laravel but the script is very simple and any PHP backend will work.

What You’ll Need

  • AWS Access Key
  • AWS Secret Key
  • AWS Region
  • AWS Bucket Name

As part of Laravel I’m using dotenv to store these values and retrieving them with getenv() but they can be stored as config variables or any other method you like.

 

The Frontend

For the most part there isn’t anything particularly complicated about the frontend markup but there are a few things to note:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="csrf-token" content="<?= csrf_token() ?>">
 
        <title>Dropzone Uploader Example</title>
        <link rel="stylesheet" href="<?= asset('css/app.css') ?>">
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.4.0/min/dropzone.min.js"></script>
        <script type="text/javascript">
            // Make AJAX POST requests work with laravel. See https://stackoverflow.com/a/47806189
            $.ajaxSetup({
                beforeSend: function(xhr, type) {
                    if (!type.crossDomain) {
                        xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content'));
                    }
                },
            });
 
            jQuery(document).ready(function($) {
                var uploader = new Dropzone('#s3dropzone', {
                    url: $('#s3dropzone').attr('action'),
                    method: "post",
                    autoProcessQueue: true,
                    maxfiles: 5,
                    timeout: null,
                    parallelUploads: 3,
                    maxThumbnailFilesize: 8, // 3MB
                    thumbnailWidth: 150,
                    thumbnailHeight: 150,
                    /**
                     *
                     * @param object   file https://developer.mozilla.org/en-US/docs/Web/API/File
                     * @param function done Use done('my error string') to return an error
                     */
                    accept: function(file, done) {
                        file.postData = [];
                        $.ajax({
                            url: "<?= route('request-s3-file-signature') ?>",
                            data: {
                                filename: file.name
                            },
                            type: 'POST',
                            dataType: 'json',
                            success: function(response) {
                                if ( !response.success )
                                    done(response.message);
 
                                delete response.success;
                                file.custom_status = 'ready';
                                file.postData = response;
                                file.s3 = response.key;
                                $(file.previewTemplate).addClass('uploading');
                                done();
                            },
                            error: function(response) {
                                file.custom_status = 'rejected';
                                if (response.responseText) {
                                    response = JSON.parse(response.responseText);
                                }
                                if (response.message) {
                                    done(response.message);
                                    return;
                                }
                                done('error preparing the upload');
                            }
                        });
                    },
                    /**
                     * Called just before each file is sent.
                     * @param object   file https://developer.mozilla.org/en-US/docs/Web/API/File
                     * @param object   xhr
                     * @param object   formData https://developer.mozilla.org/en-US/docs/Web/API/FormData
                     */
                    sending: function(file, xhr, formData) {
                        $.each(file.postData, function(k, v) {
                            formData.append(k, v);
                        });
                        formData.append('Content-type', 'application/octet-stream');
                        // formData.append('Content-length', '');
                        formData.append('acl', 'public-read');
                        formData.append('success_action_status', '200');
                    }
                });
            });
        </script>
    </head>
    <body>
        <!-- URL taken from https://stackoverflow.com/a/14061033 -->
        <form action="https://s3-<?= $AWS_REGION ?>.amazonaws.com/<?= $AWS_S3_BUCKET ?>/" id="s3dropzone">
            <div class="fallback">
                <input name="file" type="file" multiple />
            </div>
        </form>
    </body>
</html>

The important parts here are:

Avoiding Timeouts

1
timeout: null,

Without this Dropzone option our uploads are limited to 30 seconds and then time out.

Signing upload files

This is where the real work is done. Each time a file is added to dropzone, we need to grab a signature and policy document from our backend and pass that along with the file to S3.

In Dropzone’s accept callback we perform an AJAX request passing the filename to upload. This returns a JSON response containing our AWS Access Key, filepath in S3 to upload to, policy document and encrypted signature document. All of this information is attached to the File object for later use in Dropzone’s sending callback where it’s attached to the form data to be sent to S3.

This solution will work with a backend of any language at all provided it returns in the format

1
2
3
4
5
6
7
{
    success: 1|0,
    AWSAccessKeyId: string,
    key: string,
    policy: string,
    signature: string
}

Our Upload URL

The documentation as of the time of writing uses the URL structure http://<bucket>.s3.amazonaws.com/ however there are a couple of issues with this.

  1. It’s not HTTPS
  2. It doesn’t work with buckets with dots in the name

Instead thanks to user Nate on StackOverflow we instead use this URL https://s3-<region>.amazonaws.com/<bucket>/.

 

Creating Signature and Policy Documents

Here is the backend PHP for creating our signature and policy documents.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
<?php
Route::post('/ajax/request-s3-file-signature', function() {
	// Name of the file uploaded
	$filename = Request::post('filename', '');
 
	// Validate file name
    if ( !preg_match("/^[ a-zA-Z0-9\._-]+$/", $filename) )
        return response()->json([
            'success' => 0,
            'message' => "Filename must contain only letters, numbers, dots, underscores and dashes.",
        ]);
 
    // Path in our S3 bucket to upload to
    $filepath = 'tests/';
 
    // Set up our policy generator being careful to add conditions that match
    // what our HTML form will be submitting to S3
    $policy = (new App\AwsPostPolicy(getenv('AWS_ACCESS'), getenv('AWS_SECRET')))
    	->addCondition('', 'acl', 'public-read')
    	->addCondition('starts-with', '$key', $filepath)
    	->addCondition('', 'bucket', getenv('AWS_S3_BUCKET'))
    	->addCondition('', 'success_action_status', 200)
    	->addCondition('', 'content-type', "application/octet-stream");
 
    return response()->json([
        'success' => 1,
        'AWSAccessKeyId' => getenv('AWS_ACCESS'),
        'key' => $filepath . $filename,
        'policy' => $policy->getPolicy(),
        'signature' => $policy->getSignedPolicy(),
    ]);
})->name('request-s3-file-signature');
 
 
 
<?php
 
namespace App;
 
/**
 * @category    Amazon
 * @package     AWS
 * @copyright   Copyright 2009 Amazon Technologies, Inc.
 * @link        http://aws.amazon.com
 * @license     http://aws.amazon.com/apache2.0  Apache License, Version 2.0
 * @author      Michael@AWS
 */
/**
 * Create an Amazon S3 POST policy.
 *
 * @package AWS
 */
class AwsPostPolicy {
    /**
     * AWS Secret Access Key.
     *
     * @var string
     */
    protected $_awsSecretAccessKey = '';
    /**
     * AWS Access Key ID.
     *
     * @var string
     */
    protected $_awsAccessKeyId = '';
    /**
     * Amazon S3 bucket.
     *
     * @var string
     */
    protected $_bucket = '';
    /**
     * Array of conditions.
     *
     * @var array
     */
    protected $_conditions = array();
    /**
     * Duration in seconds that the policy is valid.  Default is 24 hours.
     *
     * @var int
     */
    protected $_duration = 86400;
    /**
     * Maintain a cache of un-encoded policy so that multiple requests to get
     * the policy will not incur unnecessary computation.
     *
     * @var string
     */
    protected $_policyCache = '';
    /**
     * Construct a new POST policy object.
     *
     * @param string $awsSecretAccessKey
     *      Your AWS Secret Access Key.
     *
     * @param string $bucket
     *      Amazon S3 bucket name.
     *
     * @param int $duration
     *      The number of seconds for which the policy is valid.
     */
    public function __construct($awsAccessKeyId, $awsSecretAccessKey, $bucket = '', $duration = 86400) {
        $this->_awsAccessKeyId = $awsAccessKeyId;
        $this->_awsSecretAccessKey = $awsSecretAccessKey;
        $this->_bucket = $bucket;
        $this->_duration = $duration;
        $this->_expireCache();
    }
    /**
     * Add a policy condition.
     *
     * @param string $condition
     *      Condition type.  Possible values are 'eq', 'starts-with',
     *      and 'content-length-range'.  Pass null or an empty string to set a
     *      condition that uses the {"field": "match"} syntax.  Passing anything
     *      in the $condition parameter will cause the condition to use the
     *      [condition, field, match] syntax.
     *
     * @param string $field
     *      The field name.
     *
     * @param string $match
     *      String to match.
     *
     * @return Aws_S3_PostPolicy
     *      Returns a reference to the object.
     *
     * <code>
     * $policy->addCondition('', 'acl', 'public-read');
     * // Produces: {"acl": "public-read"}
     *
     * $policy->addCondition('eq', 'acl', 'public-read');
     * // Produces: ["eq", "$acl", "public-read"]
     *
     * $policy->addCondition('starts-with', '$key', 'user/betty/');
     * // Produces: ["starts-with", "$key", "user/betty/"]
     *
     * $policy->addCondition('content-length-range', 1048579, 10485760);
     * // Produces: ["content-length-range", 1048579, 10485760]
     * </code>
     */
    public function addCondition($condition = 'eq', $field, $match = '') {
        $this->_conditions[] = array($condition, $field, $match);
        $this->_expireCache();
        return $this;
    }
    /**
     * Get the AWS Access Key ID associated with this POST policy.
     *
     * @return string
     *      Returns the AWS Access Key ID.
     *
     */
    public function getAwsAccessKeyId() {
        return $this->_awsAccessKeyId;
    }
    /**
     * Get the bucket associated with the policy.
     *
     * @return string
     *      Returns the Amazon S3 bucket name.
     */
    public function getBucket() {
        return $this->_bucket;
    }
    /**
     * Get a spefic permission based on the field name.
     *
     * @param string $field
     *      Field name to retrieve.
     *
     * @param bool $fullCondition
     *      Whether or not to return the entire condition as an array or just
     *      retrieve the match value of the condition.  By default, this is set
     *      to false so that it will only retrieve the match value of the
     *      condition.
     *
     * @return array|string
     *      If $fullCondition is set to true, then this method will return an
     *      indexed array of data about the condition:
     *      array(condition, field, match).
     *      Returns an empty array if the field is not found.
     *
     *      If $fullCondition is set to false, then this method will only return the
     *      match field of the condition.
     */
    public function getCondition($field, $fullCondition = false) {
        if (count($this->_conditions)) {
            foreach ($this->_conditions as $condition) {
                if ($condition[1] === $field) {
                    return ($fullCondition) ? $condition : $condition[2];
                }
            }
        }
        return ($fullCondition) ? array() : null;
    }
    /**
     * Get all of the conditions associated with the policy.
     *
     * @return array
     *      Returns an indexed array of conditions.
     */
    public function getConditions() {
        return $this->_conditions;
    }
    /**
     * Retrieve the POST policy.
     *
     * @param bool $encode
     *      Set to true to retrieve the encoded policy.
     *
     * @return string
     *      Returns an un-signed POST policy.
     */
    public function getPolicy($encode = false) {
        if (!$this->_policyCache) {
            $policy = sprintf('{ "expiration": "%s"', gmdate('Y-n-d\TH:i:s.000\Z', time() + $this->_duration));
            if (count($this->_conditions)) {
                $policy .= ', "conditions": [';
                $first = true;
                foreach ($this->_conditions as $condition) {
                    if (!$first) {
                        $policy .= ', ';
                    }
                    if ($condition[0]) {
                        $policy .= sprintf('["%s", "%s", "%s"]', $condition[0], $condition[1], $condition[2]);
                    } else {
                        $policy .= sprintf('{"%s": "%s"}', $condition[1], $condition[2]);
                    }
                    $first = false;
                }
                $policy .= ']';
            }
            $policy .= '}';
            $this->_policyCache = $policy;
        } else {
            $policy = $this->_policyCache;
        }
        if (!$encode) {
            return base64_encode($policy);
        } else {
            return base64_encode(utf8_encode(preg_replace('/\s\s+|\\f|\\n|\\r|\\t|\\v/', '', $policy)));
        }
    }
    /**
     * Get a signed POST policy.
     *
     * @return string
     *      Returns a signed POST policy.
     */
    public function getSignedPolicy() {
        return base64_encode(hash_hmac('sha1', $this->getPolicy(), $this->_awsSecretAccessKey, true));
    }
    /**
     * Expire the policy cache.
     *
     * @return Aws_S3_Policy
     *      Returns a reference to the object.
     */
    protected function _expireCache() {
        $this->_policyCache = '';
        return $this;
    }
    /**
     * Reset the policy by clearing the conditions, removing the bucket name,
     * and making the duration 86400 seconds (24 hours).
     *
     * @return Aws_S3_PostPolicy
     *      Returns a reference to the object.
     */
    public function reset() {
        $this->_conditions = array();
        $this->_bucket = '';
        $this->_duration = 0;
        $this->_expireCache();
        return $this;
    }
    /**
     * Set the Amazon S3 bucket.
     *
     * @param string $bucket
     *      Amazon S3 bucket name.
     *
     * @return Aws_S3_PostPolicy
     *      Returns a reference to the object.
     */
    public function setBucket($bucket) {
        $this->_bucket = $bucket;
        return $this;
    }
    /**
     * Set the number of seconds the policy is valid.
     *
     * @param int $seconds
     *      The number of seconds the policy should be valid.
     *
     * @return Aws_S3_PostPolicy
     *      Returns a reference to the object.
     */
    public function setDuration($seconds) {
        $this->_duration = $seconds;
        $this->_expireCache();
        return $this;
    }
}

The AwsPostPolicy class handles all the heavy lifting for us – we just need to plug in our Access and Secret keys as well as adding a condition to match each field our HTML Form is passing to S3 during the upload.