Completed
Push — master ( fa499c...038a73 )
by Edd
01:59
created

Signature::buildCustomUrl()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 12
rs 9.4285
cc 3
eloc 6
nc 3
nop 0
1
<?php
2
3
namespace EddTurtle\DirectUpload;
4
5
/**
6
 * Class Signature
7
 *
8
 * Build an AWS Signature, ready for direct upload. This will support AWS's signature v4 so should be
9
 * accepted by all regions.
10
 *
11
 * @package EddTurtle\DirectUpload
12
 */
13
class Signature
14
{
15
16
    CONST ALGORITHM = "AWS4-HMAC-SHA256";
17
    CONST SERVICE = "s3";
18
    CONST REQUEST_TYPE = "aws4_request";
19
20
    /**
21
     * Default options, these can be overwritten within the constructor.
22
     *
23
     * @var array
24
     */
25
    protected $options = [
26
27
        // If the upload is a success, this is the http code we get back from S3.
28
        // By default this will be a 201 Created.
29
        'success_status' => 201,
30
31
        // If the file should be private/public-read/public-write.
32
        // This is file specific, not bucket. More info: http://amzn.to/1SSOgwO
33
        'acl' => 'private',
34
35
        // The file's name on s3, can be set with JS by changing the input[name="key"].
36
        // ${filename} will just mean the original filename of the file being uploaded.
37
        'default_filename' => '${filename}',
38
39
        // The maximum file size of an upload in MB. Will refuse with a EntityTooLarge
40
        // and 400 Bad Request if you exceed this limit.
41
        'max_file_size' => 500,
42
43
        // Request expiration time, specified in relative time format or in seconds.
44
        // minimum of 1 (+1 second), maximum of 604800 (+7 days)
45
        'expires' => '+6 hours',
46
47
        // Server will check that the filename starts with this prefix and fail
48
        // with a AccessDenied 403 if not.
49
        'valid_prefix' => '',
50
51
        // Strictly only allow a single content type, blank will allow all. Will fail
52
        // with a AccessDenied 403 is this condition is not met.
53
        'content_type' => '',
54
55
        // Sets whether AWS server side encryption should be applied to the uploaded files,
56
        // so that files will be encrypted with AES256 when at rest.
57
        'encryption' => false,
58
59
        //
60
        'custom_url' => null,
61
62
        // Any additional inputs to add to the form. This is an array of name => value
63
        // pairs e.g. ['Content-Disposition' => 'attachment']
64
        'additional_inputs' => []
65
66
    ];
67
68
    /**
69
     * @var string the AWS Key
70
     */
71
    private $key;
72
73
    /**
74
     * @var string the AWS Secret
75
     */
76
    private $secret;
77
78
    /**
79
     * @var string
80
     */
81
    private $bucket;
82
83
    /**
84
     * @var Region
85
     */
86
    private $region;
87
88
    /**
89
     * @var int the current unix timestamp
90
     */
91
    private $time = null;
92
93
    private $credentials = null;
94
    private $base64Policy = null;
95
    private $signature = null;
96
97
    /**
98
     * Signature constructor.
99
     *
100
     * @param string $key     the AWS API Key to use.
101
     * @param string $secret  the AWS API Secret to use.
102
     * @param string $bucket  the bucket to upload the file into.
103
     * @param string $region  the s3 region this bucket is within. More info: http://amzn.to/1FtPG6r
104
     * @param array  $options any additional options, like acl and success status.
105
     */
106
    public function __construct($key, $secret, $bucket, $region = "us-east-1", $options = [])
107
    {
108
        $this->setAwsCredentials($key, $secret);
109
        $this->populateTime();
110
111
        $this->bucket = $bucket;
112
        $this->region = new Region($region);
113
114
        $this->setOptions($options);
115
    }
116
117
    /**
118
     * Set the AWS Credentials
119
     *
120
     * @param string $key    the AWS API Key to use.
121
     * @param string $secret the AWS API Secret to use.
122
     */
123
    protected function setAwsCredentials($key, $secret)
124
    {
125
        // Key
126
        if (empty($key) || $key === "YOUR_S3_KEY") {
127
            throw new \InvalidArgumentException("Invalid AWS Key");
128
        }
129
        $this->key = $key;
130
131
        // Secret
132
        if (empty($secret) || $secret === "YOUR_S3_SECRET") {
133
            throw new \InvalidArgumentException("Invalid AWS Secret");
134
        }
135
        $this->secret = $secret;
136
    }
137
138
    /**
139
     * Build the form url for sending files, this will include the bucket and the region name.
140
     *
141
     * @return string the s3 bucket's url.
142
     */
143
    public function getFormUrl()
144
    {
145
        if (!is_null($this->options['custom_url'])) {
146
            return $this->buildCustomUrl();
147
        } else {
148
            return $this->buildAmazonUrl();
149
        }
150
    }
151
152
    private function buildCustomUrl()
153
    {
154
        $url = trim($this->options['custom_url']);
155
156
        if (filter_var($url, FILTER_VALIDATE_URL) === false) {
157
            throw new InvalidOptionException("The custom_url option you have specified is invalid");
158
        }
159
160
        $separator = (substr($url, -1) === "/" ? "" : "/");
161
162
        return $url . $separator . urlencode($this->bucket);
163
    }
164
165
    private function buildAmazonUrl()
166
    {
167
        $region = (string)$this->region;
168
169
        // Only the us-east-1 region is exempt from needing the region in the url.
170
        if ($region !== "us-east-1") {
171
            $middle = "-" . $region;
172
        } else {
173
            $middle = "";
174
        }
175
176
        return "//" . self::SERVICE . $middle . ".amazonaws.com" . "/" . urlencode($this->bucket);
177
    }
178
179
    /**
180
     * Get all options.
181
     *
182
     * @return array
183
     */
184
    public function getOptions()
185
    {
186
        return $this->options;
187
    }
188
189
    /**
190
     * Set/overwrite any default options.
191
     *
192
     * @param array $options any options to override.
193
     */
194
    public function setOptions($options)
195
    {
196
        // Overwrite default options
197
        $this->options = $options + $this->options;
198
199
        $this->options['acl'] = new Acl($this->options['acl']);
200
201
        // Return HTTP code must be a string
202
        $this->options['success_status'] = (string)$this->options['success_status'];
203
    }
204
205
    /**
206
     * Get an AWS Signature V4 generated.
207
     *
208
     * @return string the aws v4 signature.
209
     */
210
    public function getSignature()
211
    {
212
        if (is_null($this->signature)) {
213
            $this->generateScope();
214
            $this->generatePolicy();
215
            $this->generateSignature();
216
        }
217
        return $this->signature;
218
    }
219
220
    /**
221
     * Generate the necessary hidden inputs to go within the form. These inputs should match what's being send in
222
     * the policy.
223
     *
224
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
225
     *
226
     * @return array of the form inputs.
227
     */
228
    public function getFormInputs($addKey = true)
229
    {
230
        $this->getSignature();
231
232
        $inputs = [
233
            'Content-Type' => $this->options['content_type'],
234
            'acl' => (string)$this->options['acl'],
235
            'success_action_status' => $this->options['success_status'],
236
            'policy' => $this->base64Policy,
237
            'X-amz-credential' => $this->credentials,
238
            'X-amz-algorithm' => self::ALGORITHM,
239
            'X-amz-date' => $this->getFullDateFormat(),
240
            'X-amz-signature' => $this->signature
241
        ];
242
243
        if ($this->options['encryption']) {
244
            $inputs['X-amz-server-side-encryption'] = 'AES256';
245
        }
246
247
        $inputs = array_merge($inputs, $this->options['additional_inputs']);
248
249
        if ($addKey) {
250
            // Note: The Key (filename) will need to be populated with JS on upload
251
            // if anything other than the filename is wanted.
252
            $inputs['key'] = $this->options['valid_prefix'] . $this->options['default_filename'];
253
        }
254
255
        return $inputs;
256
    }
257
258
    /**
259
     * Based on getFormInputs(), this will build up the html to go within the form.
260
     *
261
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
262
     *
263
     * @return string html of hidden form inputs.
264
     */
265
    public function getFormInputsAsHtml($addKey = true)
266
    {
267
        $inputs = [];
268
        foreach ($this->getFormInputs($addKey) as $name => $value) {
269
            $inputs[] = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
270
        }
271
        return implode(PHP_EOL, $inputs);
272
    }
273
274
275
    // Where the magic begins ;)
276
277
    /**
278
     * Step 1: Generate the Scope
279
     */
280
    protected function generateScope()
281
    {
282
        $scope = [
283
            $this->key,
284
            $this->getShortDateFormat(),
285
            $this->region,
286
            self::SERVICE,
287
            self::REQUEST_TYPE
288
        ];
289
        $this->credentials = implode('/', $scope);
290
    }
291
292
    /**
293
     * Step 2: Generate a Base64 Policy
294
     */
295
    protected function generatePolicy()
296
    {
297
        $policy = [
298
            'expiration' => $this->getExpirationDate(),
299
            'conditions' => [
300
                ['bucket' => $this->bucket],
301
                ['acl' => (string)$this->options['acl']],
302
                ['starts-with', '$key', $this->options['valid_prefix']],
303
                $this->getPolicyContentTypeArray(),
304
                ['content-length-range', 0, $this->mbToBytes($this->options['max_file_size'])],
305
                ['success_action_status' => $this->options['success_status']],
306
                ['x-amz-credential' => $this->credentials],
307
                ['x-amz-algorithm' => self::ALGORITHM],
308
                ['x-amz-date' => $this->getFullDateFormat()]
309
            ]
310
        ];
311
        $policy = $this->addAdditionalInputs($policy);
312
        $this->base64Policy = base64_encode(json_encode($policy));
313
    }
314
315
    private function getPolicyContentTypeArray()
316
    {
317
        $contentTypePrefix = (empty($this->options['content_type']) ? 'starts-with' : 'eq');
318
        return [
319
            $contentTypePrefix,
320
            '$Content-Type',
321
            $this->options['content_type']
322
        ];
323
    }
324
325
    private function addAdditionalInputs($policy)
0 ignored issues
show
Documentation introduced by
The return type could not be reliably inferred; please add a @return annotation.

Our type inference engine in quite powerful, but sometimes the code does not provide enough clues to go by. In these cases we request you to add a @return annotation as described here.

Loading history...
326
    {
327
        foreach ($this->options['additional_inputs'] as $name => $value) {
328
            $policy['conditions'][] = ['starts-with', '$' . $name, $value];
329
        }
330
        return $policy;
331
    }
332
333
    /**
334
     * Step 3: Generate and sign the Signature (v4)
335
     */
336
    protected function generateSignature()
337
    {
338
        $signatureData = [
339
            $this->getShortDateFormat(),
340
            (string)$this->region,
341
            self::SERVICE,
342
            self::REQUEST_TYPE
343
        ];
344
345
        // Iterates over the data (defined in the array above), hashing it each time.
346
        $initial = 'AWS4' . $this->secret;
347
        $signingKey = array_reduce($signatureData, function($key, $data) {
348
            return $this->keyHash($data, $key);
349
        }, $initial);
350
351
        // Finally, use the signing key to hash the policy.
352
        $this->signature = $this->keyHash($this->base64Policy, $signingKey, false);
353
    }
354
355
356
    // Helper functions
357
358
    private function keyHash($date, $key, $raw = true)
359
    {
360
        return hash_hmac('sha256', $date, $key, $raw);
361
    }
362
363
    private function populateTime()
364
    {
365
        if (is_null($this->time)) {
366
            $this->time = time();
367
        }
368
    }
369
370
    private function mbToBytes($megaByte)
371
    {
372
        if (is_numeric($megaByte)) {
373
            return $megaByte * pow(1024, 2);
374
        }
375
        return 0;
376
    }
377
378
379
    // Dates
380
381
    private function getShortDateFormat()
382
    {
383
        return gmdate("Ymd", $this->time);
384
    }
385
386
    private function getFullDateFormat()
387
    {
388
        return gmdate("Ymd\THis\Z", $this->time);
389
    }
390
391
    private function getExpirationDate()
392
    {
393
        // Note: using \DateTime::ISO8601 doesn't work :(
394
395
        $exp = strtotime($this->options['expires'], $this->time);
396
        $diff = $exp - $this->time;
397
398
        if (!($diff >= 1 && $diff <= 604800)) {
399
            throw new \InvalidArgumentException("Expiry must be between 1 and 604800");
400
        }
401
402
        return gmdate('Y-m-d\TG:i:s\Z', $exp);
403
    }
404
405
406
}
407