Completed
Push — master ( 8c79df...2723f8 )
by Edd
02:08
created

Signature::generatePolicy()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 19
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 15
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
        // 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
        // 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) || $key === "YOUR_S3_KEY") {
103
            throw new \InvalidArgumentException("Invalid AWS Key");
104
        }
105
        $this->key = $key;
106
107
        // Secret
108
        if (empty($secret) || $secret === "YOUR_S3_SECRET") {
109
            throw new \InvalidArgumentException("Invalid AWS Secret");
110
        }
111
        $this->secret = $secret;
112
    }
113
114
    /**
115
     * Build the form url for sending files, this will include the bucket and the region name.
116
     *
117
     * @return string the s3 bucket's url.
118
     */
119
    public function getFormUrl()
120
    {
121
        $region = (string)$this->region;
122
123
        // Only the us-east-1 region is exempt from needing the region in the url.
124
        if ($region !== "us-east-1") {
125
            $middle = "-" . $region;
126
        } else {
127
            $middle = "";
128
        }
129
130
        return "//" . urlencode($this->bucket) . "." . self::SERVICE . $middle . ".amazonaws.com";
131
    }
132
133
    /**
134
     * Get all options.
135
     *
136
     * @return array
137
     */
138
    public function getOptions()
139
    {
140
        return $this->options;
141
    }
142
143
    /**
144
     * Set/overwrite any default options.
145
     *
146
     * @param array $options any options to override.
147
     */
148
    public function setOptions($options)
149
    {
150
        $this->options = $options + $this->options;
151
        $this->options['acl'] = new Acl($this->options['acl']);
152
153
        // Return HTTP code must be a string
154
        $this->options['success_status'] = (string)$this->options['success_status'];
155
    }
156
157
    /**
158
     * Get an AWS Signature V4 generated.
159
     *
160
     * @return string the aws v4 signature.
161
     */
162
    public function getSignature()
163
    {
164
        if (is_null($this->signature)) {
165
            $this->generateScope();
166
            $this->generatePolicy();
167
            $this->generateSignature();
168
        }
169
        return $this->signature;
170
    }
171
172
    /**
173
     * Generate the necessary hidden inputs to go within the form. These inputs should match what's being send in
174
     * the policy.
175
     *
176
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
177
     *
178
     * @return array of the form inputs.
179
     */
180
    public function getFormInputs($addKey = true)
181
    {
182
        $this->getSignature();
183
184
        $inputs = [
185
            'Content-Type' => $this->options['content_type'],
186
            'acl' => $this->options['acl'],
187
            'success_action_status' => $this->options['success_status'],
188
            'policy' => $this->base64Policy,
189
            'X-amz-credential' => $this->credentials,
190
            'X-amz-algorithm' => self::ALGORITHM,
191
            'X-amz-date' => $this->getFullDateFormat(),
192
            'X-amz-signature' => $this->signature
193
        ];
194
195
        $inputs = array_merge($inputs, $this->options['additional_inputs']);
196
197
        if ($addKey) {
198
            // Note: The Key (filename) will need to be populated with JS on upload
199
            // if anything other than the filename is wanted.
200
            $inputs['key'] = $this->options['valid_prefix'] . $this->options['default_filename'];
201
        }
202
203
        return $inputs;
204
    }
205
206
    /**
207
     * Based on getFormInputs(), this will build up the html to go within the form.
208
     *
209
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
210
     *
211
     * @return string html of hidden form inputs.
212
     */
213
    public function getFormInputsAsHtml($addKey = true)
214
    {
215
        $inputs = [];
216
        foreach ($this->getFormInputs($addKey) as $name => $value) {
217
            $inputs[] = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
218
        }
219
        return implode(PHP_EOL, $inputs);
220
    }
221
222
223
    // Where the magic begins ;)
224
225
    /**
226
     * Step 1: Generate the Scope
227
     */
228
    protected function generateScope()
229
    {
230
        $scope = [
231
            $this->key,
232
            $this->getShortDateFormat(),
233
            $this->region,
234
            self::SERVICE,
235
            self::REQUEST_TYPE
236
        ];
237
        $this->credentials = implode('/', $scope);
238
    }
239
240
    /**
241
     * Step 2: Generate a Base64 Policy
242
     */
243
    protected function generatePolicy()
244
    {
245
        $policy = [
246
            'expiration' => $this->getExpirationDate(),
247
            'conditions' => [
248
                ['bucket' => $this->bucket],
249
                ['acl' => (string)$this->options['acl']],
250
                ['starts-with', '$key', $this->options['valid_prefix']],
251
                $this->getPolicyContentTypeArray(),
252
                ['content-length-range', 0, $this->mbToBytes($this->options['max_file_size'])],
253
                ['success_action_status' => $this->options['success_status']],
254
                ['x-amz-credential' => $this->credentials],
255
                ['x-amz-algorithm' => self::ALGORITHM],
256
                ['x-amz-date' => $this->getFullDateFormat()]
257
            ]
258
        ];
259
        $policy = $this->addAdditionalInputs($policy);
260
        $this->base64Policy = base64_encode(json_encode($policy));
261
    }
262
263
    private function getPolicyContentTypeArray()
264
    {
265
        $contentTypePrefix = (empty($this->options['content_type']) ? 'starts-with' : 'eq');
266
        return [
267
            $contentTypePrefix,
268
            '$Content-Type',
269
            $this->options['content_type']
270
        ];
271
    }
272
273
    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...
274
    {
275
        foreach ($this->options['additional_inputs'] as $name => $value) {
276
            $policy['conditions'][] = ['starts-with', '$' . $name, $value];
277
        }
278
        return $policy;
279
    }
280
281
    /**
282
     * Step 3: Generate and sign the Signature (v4)
283
     */
284
    protected function generateSignature()
285
    {
286
        $signatureData = [
287
            $this->getShortDateFormat(),
288
            (string)$this->region,
289
            self::SERVICE,
290
            self::REQUEST_TYPE
291
        ];
292
293
        // Iterates over the data (defined in the array above), hashing it each time.
294
        $initial = 'AWS4' . $this->secret;
295
        $signingKey = array_reduce($signatureData, function($key, $data) {
296
            return $this->keyHash($data, $key);
297
        }, $initial);
298
299
        // Finally, use the signing key to hash the policy.
300
        $this->signature = $this->keyHash($this->base64Policy, $signingKey, false);
301
    }
302
303
304
    // Helper functions
305
306
    private function keyHash($date, $key, $raw = true)
307
    {
308
        return hash_hmac('sha256', $date, $key, $raw);
309
    }
310
311
    private function populateTime()
312
    {
313
        if (is_null($this->time)) {
314
            $this->time = time();
315
        }
316
    }
317
318
    private function mbToBytes($megaByte)
319
    {
320
        if (is_numeric($megaByte)) {
321
            return $megaByte * pow(1024, 2);
322
        }
323
        return 0;
324
    }
325
326
327
    // Dates
328
329
    private function getShortDateFormat()
330
    {
331
        return gmdate("Ymd", $this->time);
332
    }
333
334
    private function getFullDateFormat()
335
    {
336
        return gmdate("Ymd\THis\Z", $this->time);
337
    }
338
339
    private function getExpirationDate()
340
    {
341
        // Note: using \DateTime::ISO8601 doesn't work :(
342
343
        $exp = strtotime($this->options['expires'], $this->time);
344
        $diff = $exp - $this->time;
345
346
        if (!($diff >= 1 && $diff <= 604800)) {
347
            throw new \InvalidArgumentException("Expiry must be between 1 and 604800");
348
        }
349
350
        return gmdate('Y-m-d\TG:i:s\Z', $exp);
351
    }
352
353
354
}
355