Completed
Push — master ( 73d18a...de6a87 )
by Edd
03:29
created

Signature::setTime()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 3
nc 3
nop 1
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
        // Allow S3 compatible solutions by specifying the domain it should POST to. Must be
60
        // a valid url (inc. http/https) otherwise will throw InvalidOptionException.
61
        'custom_url' => null,
62
63
        // Set Amazon S3 Transfer Acceleration
64
        'accelerate' => false,
65
66
        // Any additional inputs to add to the form. This is an array of name => value
67
        // pairs e.g. ['Content-Disposition' => 'attachment']
68
        'additional_inputs' => []
69
70
    ];
71
72
    /**
73
     * @var string the AWS Key
74
     */
75
    private $key;
76
77
    /**
78
     * @var string the AWS Secret
79
     */
80
    private $secret;
81
82
    /**
83
     * @var string
84
     */
85
    private $bucket;
86
87
    /**
88
     * @var Region
89
     */
90
    private $region;
91
92
    /**
93
     * @var int the current unix timestamp
94
     */
95
    private $time = null;
96
97
    private $credentials = null;
98
    private $base64Policy = null;
99
    private $signature = null;
100
101
    /**
102
     * Signature constructor.
103
     *
104
     * @param string $key     the AWS API Key to use.
105
     * @param string $secret  the AWS API Secret to use.
106
     * @param string $bucket  the bucket to upload the file into.
107
     * @param string $region  the s3 region this bucket is within. More info: http://amzn.to/1FtPG6r
108
     * @param array  $options any additional options, like acl and success status.
109
     */
110
    public function __construct($key, $secret, $bucket, $region = "us-east-1", $options = [])
111
    {
112
        $this->setAwsCredentials($key, $secret);
113
        $this->setTime();
114
115
        $this->bucket = $bucket;
116
        $this->region = new Region($region);
117
118
        $this->setOptions($options);
119
    }
120
121
    /**
122
     * Set the AWS Credentials
123
     *
124
     * @param string $key    the AWS API Key to use.
125
     * @param string $secret the AWS API Secret to use.
126
     */
127
    protected function setAwsCredentials($key, $secret)
128
    {
129
        // Key
130
        if (empty($key) || $key === "YOUR_S3_KEY") {
131
            throw new \InvalidArgumentException("Invalid AWS Key");
132
        }
133
        $this->key = $key;
134
135
        // Secret
136
        if (empty($secret) || $secret === "YOUR_S3_SECRET") {
137
            throw new \InvalidArgumentException("Invalid AWS Secret");
138
        }
139
        $this->secret = $secret;
140
    }
141
142
    /**
143
     * Build the form url for sending files, this will include the bucket and the region name.
144
     *
145
     * @return string the s3 bucket's url.
146
     */
147
    public function getFormUrl()
148
    {
149
        if (!is_null($this->options['custom_url'])) {
150
            return $this->buildCustomUrl();
151
        } else {
152
            return $this->buildAmazonUrl();
153
        }
154
    }
155
156
    private function buildCustomUrl()
157
    {
158
        $url = trim($this->options['custom_url']);
159
160
        if (filter_var($url, FILTER_VALIDATE_URL) === false) {
161
            throw new InvalidOptionException("The custom_url option you have specified is invalid");
162
        }
163
164
        $separator = (substr($url, -1) === "/" ? "" : "/");
165
166
        return $url . $separator . urlencode($this->bucket);
167
    }
168
169
    private function buildAmazonUrl()
170
    {
171
        $region = (string)$this->region;
172
173
        // Only the us-east-1 region is exempt from needing the region in the url.
174
        if ($region !== "us-east-1") {
175
            $middle = "-" . $region;
176
        } else {
177
            $middle = "";
178
        }
179
180
        if ($this->options['accelerate']) {
181
            return "//" . urlencode($this->bucket) . "." . self::SERVICE . "-accelerate.amazonaws.com";
182
        } else {
183
            return "//" . self::SERVICE . $middle . ".amazonaws.com" . "/" . urlencode($this->bucket);
184
        }
185
    }
186
187
    /**
188
     * Get all options.
189
     *
190
     * @return array
191
     */
192
    public function getOptions()
193
    {
194
        return $this->options;
195
    }
196
197
    /**
198
     * Set/overwrite any default options.
199
     *
200
     * @param array $options any options to override.
201
     */
202
    public function setOptions($options)
203
    {
204
        // Overwrite default options
205
        $this->options = $options + $this->options;
206
207
        $this->options['acl'] = new Acl($this->options['acl']);
208
209
        // Return HTTP code must be a string
210
        $this->options['success_status'] = (string)$this->options['success_status'];
211
212
        // Encryption option is just a helper to set this header, but we need to set it early on so it
213
        // affects both the policy and the inputs generated.
214
        if ($this->options['encryption']) {
215
            $this->options['additional_inputs']['X-amz-server-side-encryption'] = 'AES256';
216
        }
217
    }
218
219
    /**
220
     * Get an AWS Signature V4 generated.
221
     *
222
     * @return string the aws v4 signature.
223
     */
224
    public function getSignature()
225
    {
226
        if (is_null($this->signature)) {
227
            $this->generateScope();
228
            $this->generatePolicy();
229
            $this->generateSignature();
230
        }
231
        return $this->signature;
232
    }
233
234
    /**
235
     * Generate the necessary hidden inputs to go within the form. These inputs should match what's being send in
236
     * the policy.
237
     *
238
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
239
     *
240
     * @return array of the form inputs.
241
     */
242
    public function getFormInputs($addKey = true)
243
    {
244
        $this->getSignature();
245
246
        $inputs = [
247
            'Content-Type' => $this->options['content_type'],
248
            'acl' => (string)$this->options['acl'],
249
            'success_action_status' => $this->options['success_status'],
250
            'policy' => $this->base64Policy,
251
            'X-amz-credential' => $this->credentials,
252
            'X-amz-algorithm' => self::ALGORITHM,
253
            'X-amz-date' => $this->getFullDateFormat(),
254
            'X-amz-signature' => $this->signature
255
        ];
256
257
        $inputs = array_merge($inputs, $this->options['additional_inputs']);
258
259
        if ($addKey) {
260
            // Note: The Key (filename) will need to be populated with JS on upload
261
            // if anything other than the filename is wanted.
262
            $inputs['key'] = $this->options['valid_prefix'] . $this->options['default_filename'];
263
        }
264
265
        return $inputs;
266
    }
267
268
    /**
269
     * Based on getFormInputs(), this will build up the html to go within the form.
270
     *
271
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
272
     *
273
     * @return string html of hidden form inputs.
274
     */
275
    public function getFormInputsAsHtml($addKey = true)
276
    {
277
        $inputs = [];
278
        foreach ($this->getFormInputs($addKey) as $name => $value) {
279
            $inputs[] = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
280
        }
281
        return implode(PHP_EOL, $inputs);
282
    }
283
284
285
    // Where the magic begins ;)
286
287
    /**
288
     * Step 1: Generate the Scope
289
     */
290
    protected function generateScope()
291
    {
292
        $scope = [
293
            $this->key,
294
            $this->getShortDateFormat(),
295
            $this->region,
296
            self::SERVICE,
297
            self::REQUEST_TYPE
298
        ];
299
        $this->credentials = implode('/', $scope);
300
    }
301
302
    /**
303
     * Step 2: Generate a Base64 Policy
304
     */
305
    protected function generatePolicy()
306
    {
307
        $policy = [
308
            'expiration' => $this->getExpirationDate(),
309
            'conditions' => [
310
                ['bucket' => $this->bucket],
311
                ['acl' => (string)$this->options['acl']],
312
                ['starts-with', '$key', $this->options['valid_prefix']],
313
                $this->getPolicyContentTypeArray(),
314
                ['content-length-range', 0, $this->mbToBytes($this->options['max_file_size'])],
315
                ['success_action_status' => $this->options['success_status']],
316
                ['x-amz-credential' => $this->credentials],
317
                ['x-amz-algorithm' => self::ALGORITHM],
318
                ['x-amz-date' => $this->getFullDateFormat()]
319
            ]
320
        ];
321
        $policy = $this->addAdditionalInputs($policy);
322
        $this->base64Policy = base64_encode(json_encode($policy));
323
    }
324
325
    private function getPolicyContentTypeArray()
326
    {
327
        $contentTypePrefix = (empty($this->options['content_type']) ? 'starts-with' : 'eq');
328
        return [
329
            $contentTypePrefix,
330
            '$Content-Type',
331
            $this->options['content_type']
332
        ];
333
    }
334
335
    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...
336
    {
337
        foreach ($this->options['additional_inputs'] as $name => $value) {
338
            $policy['conditions'][] = ['starts-with', '$' . $name, $value];
339
        }
340
        return $policy;
341
    }
342
343
    /**
344
     * Step 3: Generate and sign the Signature (v4)
345
     */
346
    protected function generateSignature()
347
    {
348
        $signatureData = [
349
            $this->getShortDateFormat(),
350
            (string)$this->region,
351
            self::SERVICE,
352
            self::REQUEST_TYPE
353
        ];
354
355
        // Iterates over the data (defined in the array above), hashing it each time.
356
        $initial = 'AWS4' . $this->secret;
357
        $signingKey = array_reduce($signatureData, function($key, $data) {
358
            return $this->keyHash($data, $key);
359
        }, $initial);
360
361
        // Finally, use the signing key to hash the policy.
362
        $this->signature = $this->keyHash($this->base64Policy, $signingKey, false);
363
    }
364
365
    public function setTime($time = null)
366
    {
367
        if (!is_null($time)) {
368
            $this->time = $time;
369
        } else if (is_null($this->time)) {
370
            $this->time = time();
371
        }
372
    }
373
374
375
    // Helper functions
376
377
    private function keyHash($date, $key, $raw = true)
378
    {
379
        return hash_hmac('sha256', $date, $key, $raw);
380
    }
381
382
    private function mbToBytes($megaByte)
383
    {
384
        if (is_numeric($megaByte)) {
385
            return $megaByte * pow(1024, 2);
386
        }
387
        return 0;
388
    }
389
390
391
    // Dates
392
393
    private function getShortDateFormat()
394
    {
395
        return gmdate("Ymd", $this->time);
396
    }
397
398
    private function getFullDateFormat()
399
    {
400
        return gmdate("Ymd\THis\Z", $this->time);
401
    }
402
403
    private function getExpirationDate()
404
    {
405
        // Note: using \DateTime::ISO8601 doesn't work :(
406
407
        $exp = strtotime($this->options['expires'], $this->time);
408
        $diff = $exp - $this->time;
409
410
        if (!($diff >= 1 && $diff <= 604800)) {
411
            throw new \InvalidArgumentException("Expiry must be between 1 and 604800");
412
        }
413
414
        return gmdate('Y-m-d\TG:i:s\Z', $exp);
415
    }
416
417
418
}
419