Signature::buildCustomUrl()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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