Completed
Push — master ( b511c6...afb37c )
by Edd
02:38
created

Signature::getOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 1
Metric Value
c 1
b 1
f 1
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
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
        // min: 1 (+1 second), max: 604800 (+7 days)
0 ignored issues
show
Unused Code Comprehensibility introduced by
40% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
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
        // Any additional inputs to add to the form. This is an array of name => value
56
        // pairs e.g. ['Content-Disposition' => 'attachment']
57
        'additional_inputs' => []
58
        
59
    ];
60
61
    private $key;
62
    private $secret;
63
64
    private $bucket;
65
    private $region;
66
67
    private $time = null;
68
69
    private $credentials = null;
70
    private $base64Policy = null;
71
    private $signature = null;
72
73
    /**
74
     * Signature constructor.
75
     *
76
     * @param string $key     the AWS API Key to use.
77
     * @param string $secret  the AWS API Secret to use.
78
     * @param string $bucket  the bucket to upload the file into.
79
     * @param string $region  the s3 region this bucket is within. More info: http://amzn.to/1FtPG6r
80
     * @param array  $options any additional options, like acl and success status.
81
     */
82
    public function __construct($key, $secret, $bucket, $region = "us-east-1", $options = [])
83
    {
84
        $this->setAwsCredentials($key, $secret);
85
        $this->populateTime();
86
87
        $this->bucket = $bucket;
88
        $this->region = new Region($region);
89
90
        $this->setOptions($options);
91
    }
92
93
    /**
94
     * Set the AWS Credentials
95
     *
96
     * @param string $key    the AWS API Key to use.
97
     * @param string $secret the AWS API Secret to use.
98
     */
99
    protected function setAwsCredentials($key, $secret)
100
    {
101
        // Key
102
        if (!empty($key)) {
103
            $this->key = $key;
104
        } else {
105
            throw new \InvalidArgumentException("Invalid AWS Key");
106
        }
107
108
        // Secret
109
        if (!empty($secret)) {
110
            $this->secret = $secret;
111
        } else {
112
            throw new \InvalidArgumentException("Invalid AWS Secret");
113
        }
114
    }
115
116
    /**
117
     * Build the form url for sending files, this will include the bucket and the region name.
118
     *
119
     * @return string the s3 bucket's url.
120
     */
121
    public function getFormUrl()
122
    {
123
        $region = $this->region->getName();
124
125
        // Only the us-east-1 region is exempt from needing the region in the url.
126
        if ($region !== "us-east-1") {
127
            $middle = "-" . $region;
128
        } else {
129
            $middle = "";
130
        }
131
132
        return "//" . urlencode($this->bucket) . "." . self::SERVICE . $middle . ".amazonaws.com";
133
    }
134
135
    /**
136
     * Get all options.
137
     *
138
     * @return array
139
     */
140
    public function getOptions()
141
    {
142
        return $this->options;
143
    }
144
145
    /**
146
     * Set/overwrite any default options.
147
     *
148
     * @param array $options any options to override.
149
     */
150
    public function setOptions($options)
151
    {
152
        $this->options = $options + $this->options;
153
        $this->options['acl'] = new Acl($this->options['acl']);
154
155
        // Return HTTP code must be a string
156
        $this->options['success_status'] = (string)$this->options['success_status'];
157
    }
158
159
    /**
160
     * Get an AWS Signature V4 generated.
161
     *
162
     * @return string the aws v4 signature.
163
     */
164
    public function getSignature()
165
    {
166
        if (is_null($this->signature)) {
167
            $this->generateScope();
168
            $this->generatePolicy();
169
            $this->generateSignature();
170
        }
171
        return $this->signature;
172
    }
173
174
    /**
175
     * Generate the necessary hidden inputs to go within the form. These inputs should match what's being send in
176
     * the policy.
177
     *
178
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
179
     *
180
     * @return array of the form inputs.
181
     */
182
    public function getFormInputs($addKey = true)
183
    {
184
        $this->getSignature();
185
186
        $inputs = [
187
            'Content-Type' => $this->options['content_type'],
188
            'acl' => $this->options['acl']->getName(),
189
            'success_action_status' => $this->options['success_status'],
190
            'policy' => $this->base64Policy,
191
            'X-amz-credential' => $this->credentials,
192
            'X-amz-algorithm' => self::ALGORITHM,
193
            'X-amz-date' => $this->getFullDateFormat(),
194
            'X-amz-signature' => $this->signature
195
        ];
196
197
        $inputs = array_merge($inputs, $this->options['additional_inputs']);
198
199
        if ($addKey) {
200
            // Note: The Key (filename) will need to be populated with JS on upload
201
            // if anything other than the filename is wanted.
202
            $inputs['key'] = $this->options['default_filename'];
203
        }
204
205
        return $inputs;
206
    }
207
208
    /**
209
     * Based on getFormInputs(), this will build up the html to go within the form.
210
     *
211
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
212
     *
213
     * @return string html of hidden form inputs.
214
     */
215
    public function getFormInputsAsHtml($addKey = true)
216
    {
217
        $inputs = [];
218
        foreach ($this->getFormInputs($addKey) as $name => $value) {
219
            $inputs[] = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
220
        }
221
        return implode(PHP_EOL, $inputs);
222
    }
223
224
225
    // Where the magic begins ;)
226
227
    /**
228
     * Step 1: Generate the Scope
229
     */
230
    protected function generateScope()
231
    {
232
        $scope = [
233
            $this->key,
234
            $this->getShortDateFormat(),
235
            $this->region->getName(),
236
            self::SERVICE,
237
            self::REQUEST_TYPE
238
        ];
239
        $this->credentials = implode('/', $scope);
240
    }
241
242
    /**
243
     * Step 2: Generate a Base64 Policy
244
     */
245
    protected function generatePolicy()
246
    {
247
        // Work out options
248
        $maxSize = $this->mbToBytes($this->options['max_file_size']);
249
        $contentTypePrefix = (empty($this->options['content_type']) ? 'starts-with' : 'eq');
250
251
        // Build Policy
252
        $policy = [
253
            'expiration' => $this->getExpirationDate(),
254
            'conditions' => [
255
                ['bucket' => $this->bucket],
256
                ['acl' => $this->options['acl']->getName()],
257
                ['starts-with', '$key', $this->options['valid_prefix']],
258
                [$contentTypePrefix, '$Content-Type', $this->options['content_type']],
259
                ['content-length-range', 0, $maxSize],
260
                ['success_action_status' => $this->options['success_status']],
261
                ['x-amz-credential' => $this->credentials],
262
                ['x-amz-algorithm' => self::ALGORITHM],
263
                ['x-amz-date' => $this->getFullDateFormat()]
264
            ]
265
        ];
266
267
        // Add on the additional inputs
268
        foreach ($this->options['additional_inputs'] as $name => $value) {
269
            $policy['conditions'][] = ['starts-with', '$' . $name, $value];
270
        }
271
272
        $this->base64Policy = base64_encode(json_encode($policy));
273
    }
274
275
    /**
276
     * Step 3: Generate and sign the Signature (v4)
277
     */
278
    protected function generateSignature()
279
    {
280
        $signatureData = [
281
            $this->getShortDateFormat(),
282
            $this->region->getName(),
283
            self::SERVICE,
284
            self::REQUEST_TYPE
285
        ];
286
287
        // Iterates over the data, hashing it each time.
288
        $initial = 'AWS4' . $this->secret;
289
        $signingKey = array_reduce($signatureData, function($key, $data) {
290
            return $this->keyHash($data, $key);
291
        }, $initial);
292
293
        // Finally, use the signing key to hash the policy.
294
        $this->signature = $this->keyHash($this->base64Policy, $signingKey, false);
295
    }
296
297
298
    // Helper functions
299
300
    private function keyHash($date, $key, $raw = true)
301
    {
302
        return hash_hmac('sha256', $date, $key, $raw);
303
    }
304
305
    private function populateTime()
306
    {
307
        if (is_null($this->time)) {
308
            $this->time = time();
309
        }
310
    }
311
312
    private function mbToBytes($mb)
0 ignored issues
show
Comprehensibility introduced by
Avoid variables with short names like $mb. Configured minimum length is 3.

Short variable names may make your code harder to understand. Variable names should be self-descriptive. This check looks for variable names who are shorter than a configured minimum.

Loading history...
313
    {
314
        if (is_numeric($mb)) {
315
            return $mb * pow(1024, 2);
316
        }
317
        return 0;
318
    }
319
320
321
    // Dates
322
323
    private function getShortDateFormat()
324
    {
325
        return gmdate("Ymd", $this->time);
326
    }
327
328
    private function getFullDateFormat()
329
    {
330
        return gmdate("Ymd\THis\Z", $this->time);
331
    }
332
333
    private function getExpirationDate()
334
    {
335
        // Note: using \DateTime::ISO8601 doesn't work :(
336
337
        $exp = strtotime($this->options['expires'], $this->time);
338
        $diff = $exp - $this->time;
339
340
        if (!($diff >= 1 && $diff <= 604800)) {
341
            throw new \InvalidArgumentException("Expiry must be between 1 and 604800");
342
        }
343
344
        return gmdate('Y-m-d\TG:i:s\Z', $exp);
345
    }
346
347
348
}