Completed
Push — master ( d587ff...923e7c )
by Edd
21s queued 10s
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
use EddTurtle\DirectUpload\Exceptions\InvalidOptionException;
6
7
/**
8
 * Class Signature
9
 *
10
 * Build an AWS Signature, ready for direct upload. This will support AWS's signature v4 so should be
11
 * accepted by all regions.
12
 *
13
 * @package EddTurtle\DirectUpload
14
 */
15
class Signature
16
{
17
18
    CONST ALGORITHM = "AWS4-HMAC-SHA256";
19
    CONST SERVICE = "s3";
20
    CONST REQUEST_TYPE = "aws4_request";
21
22
    private $options;
23
24
    /**
25
     * @var string the AWS Key
26
     */
27
    private $key;
28
29
    /**
30
     * @var string the AWS Secret
31
     */
32
    private $secret;
33
34
    /**
35
     * @var string
36
     */
37
    private $bucket;
38
39
    /**
40
     * @var Region
41
     */
42
    private $region;
43
44
    /**
45
     * @var int the current unix timestamp
46
     */
47
    private $time = null;
48
49
    private $credentials = null;
50
    private $base64Policy = null;
51
    private $signature = null;
52
53
    /**
54
     * Signature constructor.
55
     *
56
     * @param string $key     the AWS API Key to use.
57
     * @param string $secret  the AWS API Secret to use.
58
     * @param string $bucket  the bucket to upload the file into.
59
     * @param string $region  the s3 region this bucket is within. More info: http://amzn.to/1FtPG6r
60
     * @param array  $options any additional options, like acl and success status.
61
     */
62
    public function __construct($key, $secret, $bucket, $region = "us-east-1", $options = [])
63
    {
64
        $this->setAwsCredentials($key, $secret);
65
        $this->time = time();
66
67
        $this->bucket = $bucket;
68
        $this->region = new Region($region);
69
70
        $this->options = new Options($options);
71
    }
72
73
    /**
74
     * Set the AWS Credentials
75
     *
76
     * @param string $key    the AWS API Key to use.
77
     * @param string $secret the AWS API Secret to use.
78
     */
79
    protected function setAwsCredentials($key, $secret): void
80
    {
81
        // Key
82
        if (empty($key)) {
83
            throw new \InvalidArgumentException("Empty AWS Key Provided");
84
        }
85
        if ($key === "YOUR_S3_KEY") {
86
            throw new \InvalidArgumentException("Invalid AWS Key Provided");
87
        }
88
        $this->key = $key;
89
90
        // Secret
91
        if (empty($secret)) {
92
            throw new \InvalidArgumentException("Empty AWS Secret Provided");
93
        }
94
        if ($secret === "YOUR_S3_SECRET") {
95
            throw new \InvalidArgumentException("Invalid AWS Secret Provided");
96
        }
97
        $this->secret = $secret;
98
    }
99
100
    /**
101
     * Build the form url for sending files, this will include the bucket and the region name.
102
     *
103
     * @return string the s3 bucket's url.
104
     */
105
    public function getFormUrl(): string
106
    {
107
        if (!is_null($this->options->get('custom_url'))) {
108
            return $this->buildCustomUrl();
109
        } else {
110
            return $this->buildAmazonUrl();
111
        }
112
    }
113
114
    private function buildCustomUrl(): string
115
    {
116
        $url = trim($this->options->get('custom_url'));
117
118
        if (filter_var($url, FILTER_VALIDATE_URL) === false) {
119
            throw new InvalidOptionException("The custom_url option you have specified is invalid");
120
        }
121
122
        $separator = (substr($url, -1) === "/" ? "" : "/");
123
124
        return $url . $separator . urlencode($this->bucket);
125
    }
126
127
    private function buildAmazonUrl(): string
128
    {
129
        $region = (string)$this->region;
130
131
        // Only the us-east-1 region is exempt from needing the region in the url.
132
        if ($region !== "us-east-1") {
133
            $middle = "-" . $region;
134
        } else {
135
            $middle = "";
136
        }
137
138
        if ($this->options->get('accelerate')) {
139
            return "//" . urlencode($this->bucket) . "." . self::SERVICE . "-accelerate.amazonaws.com";
140
        } else {
141
            return "//" . self::SERVICE . $middle . ".amazonaws.com" . "/" . urlencode($this->bucket);
142
        }
143
    }
144
145
    /**
146
     * Get all options.
147
     *
148
     * @return array
149
     */
150
    public function getOptions(): array
151
    {
152
        return $this->options->getOptions();
153
    }
154
155
    /**
156
     * Edit/Update a new list of options.
157
     *
158
     * @param array $options a list of options to update.
159
     *
160
     * @return void
161
     */
162
    public function setOptions($options): void
163
    {
164
        $this->options->setOptions($options);
165
    }
166
167
    /**
168
     * Get an AWS Signature V4 generated.
169
     *
170
     * @return string the aws v4 signature.
171
     */
172
    public function getSignature(): string
173
    {
174
        if (is_null($this->signature)) {
175
            $this->generateScope();
176
            $this->generatePolicy();
177
            $this->generateSignature();
178
        }
179
        return $this->signature;
180
    }
181
182
    /**
183
     * Generate the necessary hidden inputs to go within the form. These inputs should match what's being send in
184
     * the policy.
185
     *
186
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
187
     *
188
     * @return array of the form inputs.
189
     */
190
    public function getFormInputs($addKey = true): array
191
    {
192
        $this->getSignature();
193
194
        $inputs = [
195
            'Content-Type' => $this->options->get('content_type'),
196
            'acl' => (string)$this->options->get('acl'),
197
            'success_action_status' => $this->options->get('success_status'),
198
            'policy' => $this->base64Policy,
199
            'X-amz-credential' => $this->credentials,
200
            'X-amz-algorithm' => self::ALGORITHM,
201
            'X-amz-date' => $this->getFullDateFormat(),
202
            'X-amz-signature' => $this->signature
203
        ];
204
205
        $inputs = array_merge($inputs, $this->options->get('additional_inputs'));
206
207
        if ($addKey) {
208
            // Note: The Key (filename) will need to be populated with JS on upload
209
            // if anything other than the filename is wanted.
210
            $inputs['key'] = $this->options->get('valid_prefix') . $this->options->get('default_filename');
211
        }
212
213
        return $inputs;
214
    }
215
216
    /**
217
     * Based on getFormInputs(), this will build up the html to go within the form.
218
     *
219
     * @param bool $addKey whether to add the 'key' input (filename), defaults to yes.
220
     *
221
     * @return string html of hidden form inputs.
222
     */
223
    public function getFormInputsAsHtml($addKey = true): string
224
    {
225
        $inputs = [];
226
        foreach ($this->getFormInputs($addKey) as $name => $value) {
227
            $inputs[] = '<input type="hidden" name="' . $name . '" value="' . $value . '" />';
228
        }
229
        return implode(PHP_EOL, $inputs);
230
    }
231
232
233
    // Where the magic begins ;)
234
235
    /**
236
     * Step 1: Generate the Scope
237
     */
238
    protected function generateScope(): void
239
    {
240
        $scope = [
241
            $this->key,
242
            $this->getShortDateFormat(),
243
            $this->region,
244
            self::SERVICE,
245
            self::REQUEST_TYPE
246
        ];
247
        $this->credentials = implode('/', $scope);
248
    }
249
250
    /**
251
     * Step 2: Generate a Base64 Policy
252
     */
253
    protected function generatePolicy(): void
254
    {
255
        $policy = [
256
            'expiration' => $this->getExpirationDate(),
257
            'conditions' => [
258
                ['bucket' => $this->bucket],
259
                ['acl' => (string)$this->options->get('acl')],
260
                ['starts-with', '$key', $this->options->get('valid_prefix')],
261
                $this->getPolicyContentTypeArray(),
262
                ['content-length-range', 0, $this->mbToBytes($this->options->get('max_file_size'))],
263
                ['success_action_status' => $this->options->get('success_status')],
264
                ['x-amz-credential' => $this->credentials],
265
                ['x-amz-algorithm' => self::ALGORITHM],
266
                ['x-amz-date' => $this->getFullDateFormat()]
267
            ]
268
        ];
269
        $policy = $this->addAdditionalInputs($policy);
270
        $this->base64Policy = base64_encode(json_encode($policy));
271
    }
272
273
    private function getPolicyContentTypeArray(): array
274
    {
275
        $contentTypePrefix = (empty($this->options->get('content_type')) ? 'starts-with' : 'eq');
276
        return [
277
            $contentTypePrefix,
278
            '$Content-Type',
279
            $this->options->get('content_type')
280
        ];
281
    }
282
283
    private function addAdditionalInputs($policy): array
284
    {
285
        foreach ($this->options->get('additional_inputs') as $name => $value) {
286
            $policy['conditions'][] = ['starts-with', '$' . $name, $value];
287
        }
288
        return $policy;
289
    }
290
291
    /**
292
     * Step 3: Generate and sign the Signature (v4)
293
     */
294
    protected function generateSignature(): void
295
    {
296
        $signatureData = [
297
            $this->getShortDateFormat(),
298
            (string)$this->region,
299
            self::SERVICE,
300
            self::REQUEST_TYPE
301
        ];
302
303
        // Iterates over the data (defined in the array above), hashing it each time.
304
        $initial = 'AWS4' . $this->secret;
305
        $signingKey = array_reduce($signatureData, function ($key, $data) {
306
            return $this->keyHash($data, $key);
307
        }, $initial);
308
309
        // Finally, use the signing key to hash the policy.
310
        $this->signature = $this->keyHash($this->base64Policy, $signingKey, false);
311
    }
312
313
314
    // Helper functions
315
316
    private function keyHash($date, $key, $raw = true): string
317
    {
318
        return hash_hmac('sha256', $date, $key, $raw);
319
    }
320
321
    private function mbToBytes($megaByte): int
322
    {
323
        if (is_numeric($megaByte)) {
324
            return $megaByte * pow(1024, 2);
325
        }
326
        return 0;
327
    }
328
329
330
    // Dates
331
332
    private function getShortDateFormat(): string
333
    {
334
        return gmdate("Ymd", $this->time);
335
    }
336
337
    private function getFullDateFormat(): string
338
    {
339
        return gmdate("Ymd\THis\Z", $this->time);
340
    }
341
342
    private function getExpirationDate(): string
343
    {
344
        // Note: using \DateTime::ISO8601 doesn't work :(
345
346
        $exp = strtotime($this->options->get('expires'), $this->time);
347
        $diff = $exp - $this->time;
348
349
        if (!($diff >= 1 && $diff <= 604800)) {
350
            throw new \InvalidArgumentException("Expiry must be between 1 and 604800");
351
        }
352
353
        return gmdate('Y-m-d\TG:i:s\Z', $exp);
354
    }
355
356
357
}
358