Completed
Pull Request — master (#513)
by jeremyf
02:13
created

AwsS3::write()   B

Complexity

Conditions 9
Paths 28

Size

Total Lines 39

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 39
rs 7.7404
c 0
b 0
f 0
cc 9
nc 28
nop 2
1
<?php
2
3
namespace Gaufrette\Adapter;
4
5
use Aws\S3\Exception\S3Exception;
6
use Aws\S3\S3Client;
7
use Gaufrette\Adapter;
8
use Gaufrette\Util;
9
10
/**
11
 * Amazon S3 adapter using the AWS SDK for PHP v2.x.
12
 *
13
 * @author  Michael Dowling <[email protected]>
14
 */
15
class AwsS3 implements Adapter, MetadataSupporter, ListKeysAware, SizeCalculator, MimeTypeProvider
16
{
17
    /*
18
     * Amazon S3 does not not allow uploads > 5Go
19
     */
20
    const MAX_CONTENT_SIZE = 5368709120;
21
    const DEFAULT_PART_SIZE = 5242880;
22
23
    /** @var S3Client */
24
    protected $service;
25
    /** @var string */
26
    protected $bucket;
27
    /** @var array */
28
    protected $options;
29
    /** @var bool */
30
    protected $bucketExists;
31
    /** @var array */
32
    protected $metadata = [];
33
    /** @var bool */
34
    protected $detectContentType;
35
    /** @var int */
36
    protected $sizeLimit = self::MAX_CONTENT_SIZE;
37
    /** @var int */
38
    protected $partSize = self::DEFAULT_PART_SIZE;
39
40
    /**
41
     * @param S3Client $service
42
     * @param string   $bucket
43
     * @param array    $options
44
     * @param bool     $detectContentType
45
     */
46
    public function __construct(S3Client $service, $bucket, array $options = [], $detectContentType = false)
47
    {
48
        $this->service = $service;
49
        $this->bucket = $bucket;
50
51
        if (isset($options['size_limit']) && $options['size_limit'] <= self::MAX_CONTENT_SIZE) {
52
            $this->sizeLimit = $options['size_limit'];
53
        }
54
55
        if (isset($options['part_size']) && $options['part_size'] >= self::DEFAULT_PART_SIZE) {
56
            $this->partSize = $options['part_size'];
57
        }
58
59
        $this->options = array_replace(
60
            [
61
                'create' => false,
62
                'directory' => '',
63
                'acl' => 'private',
64
            ],
65
            $options
66
        );
67
68
        $this->detectContentType = $detectContentType;
69
    }
70
71
    /**
72
     * Gets the publicly accessible URL of an Amazon S3 object.
73
     *
74
     * @param string $key     Object key
75
     * @param array  $options Associative array of options used to buld the URL
76
     *                        - expires: The time at which the URL should expire
77
     *                        represented as a UNIX timestamp
78
     *                        - Any options available in the Amazon S3 GetObject
79
     *                        operation may be specified.
80
     *
81
     * @return string
82
     *
83
     * @deprecated 1.0 Resolving object path into URLs is out of the scope of this repository since v0.4. gaufrette/extras
84
     *                 provides a Filesystem decorator with a regular resolve() method. You should use it instead.
85
     * @see https://github.com/Gaufrette/extras
86
     */
87
    public function getUrl($key, array $options = [])
88
    {
89
        @trigger_error(
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
90
            E_USER_DEPRECATED,
91
            'Using AwsS3::getUrl() method was deprecated since v0.4. Please chek gaufrette/extras package if you want this feature'
92
        );
93
94
        return $this->service->getObjectUrl(
95
            $this->bucket,
96
            $this->computePath($key),
97
            $options['expires'] ?? null,
0 ignored issues
show
Unused Code introduced by
The call to S3Client::getObjectUrl() has too many arguments starting with $options['expires'] ?? null.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
98
            $options
99
        );
100
    }
101
102
    /**
103
     * {@inheritdoc}
104
     */
105
    public function setMetadata($key, $metadata)
106
    {
107
        // BC with AmazonS3 adapter
108
        if (isset($metadata['contentType'])) {
109
            $metadata['ContentType'] = $metadata['contentType'];
110
            unset($metadata['contentType']);
111
        }
112
113
        $this->metadata[$key] = $metadata;
114
    }
115
116
    /**
117
     * {@inheritdoc}
118
     */
119
    public function getMetadata($key)
120
    {
121
        return isset($this->metadata[$key]) ? $this->metadata[$key] : [];
122
    }
123
124
    /**
125
     * {@inheritdoc}
126
     */
127
    public function read($key)
128
    {
129
        $this->ensureBucketExists();
130
        $options = $this->getOptions($key);
131
132
        try {
133
            // Get remote object
134
            $object = $this->service->getObject($options);
135
            // If there's no metadata array set up for this object, set it up
136
            if (!array_key_exists($key, $this->metadata) || !is_array($this->metadata[$key])) {
137
                $this->metadata[$key] = [];
138
            }
139
            // Make remote ContentType metadata available locally
140
            $this->metadata[$key]['ContentType'] = $object->get('ContentType');
141
142
            return (string) $object->get('Body');
143
        } catch (\Exception $e) {
144
            return false;
145
        }
146
    }
147
148
    /**
149
     * {@inheritdoc}
150
     */
151
    public function rename($sourceKey, $targetKey)
152
    {
153
        $this->ensureBucketExists();
154
        $options = $this->getOptions(
155
            $targetKey,
156
            ['CopySource' => $this->bucket . '/' . $this->computePath($sourceKey)]
157
        );
158
159
        try {
160
            $this->service->copyObject(array_merge($options, $this->getMetadata($targetKey)));
161
162
            return $this->delete($sourceKey);
163
        } catch (\Exception $e) {
164
            return false;
165
        }
166
    }
167
168
    /**
169
     * {@inheritdoc}
170
     */
171
    public function write($key, $content)
172
    {
173
        $this->ensureBucketExists();
174
        $options = $this->getOptions($key, ['Body' => $content]);
175
176
        /*
177
         * If the ContentType was not already set in the metadata, then we autodetect
178
         * it to prevent everything being served up as binary/octet-stream.
179
         */
180
        if (!isset($options['ContentType']) && $this->detectContentType) {
181
            $options['ContentType'] = $this->guessContentType($content);
182
        }
183
184
        if ($isResource = is_resource($content)) {
185
            $size = Util\Size::fromResource($content);
186
        } else {
187
            $size = Util\Size::fromContent($content);
188
        }
189
190
        try {
191
            $success = true;
192
193
            if ($size >= $this->sizeLimit && $isResource) {
194
                $success = $this->multipartUpload($key, $content);
0 ignored issues
show
Bug introduced by
It seems like $content defined by parameter $content on line 171 can also be of type string; however, Gaufrette\Adapter\AwsS3::multipartUpload() does only seem to accept resource, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
195
            } elseif ($size <= $this->sizeLimit) {
196
                $this->service->putObject($options);
197
            } else {
198
                /*
199
                 * content is a string & too big to be uploaded in one shot
200
                 * We may want to throw an exception here ?
201
                 */
202
                return false;
203
            }
204
205
            return $success ? $size : false;
0 ignored issues
show
Bug Compatibility introduced by
The expression $success ? $size : false; of type string|integer|false adds the type string to the return on line 205 which is incompatible with the return type declared by the interface Gaufrette\Adapter::write of type integer|boolean.
Loading history...
206
        } catch (\Exception $e) {
207
            return false;
208
        }
209
    }
210
211
    /**
212
     * {@inheritdoc}
213
     */
214
    public function exists($key)
215
    {
216
        return $this->service->doesObjectExist($this->bucket, $this->computePath($key));
217
    }
218
219
    /**
220
     * {@inheritdoc}
221
     */
222 View Code Duplication
    public function mtime($key)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
223
    {
224
        try {
225
            $result = $this->service->headObject($this->getOptions($key));
226
227
            return strtotime($result['LastModified']);
228
        } catch (\Exception $e) {
229
            return false;
230
        }
231
    }
232
233
    /**
234
     * {@inheritdoc}
235
     */
236 View Code Duplication
    public function size($key)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
237
    {
238
        try {
239
            $result = $this->service->headObject($this->getOptions($key));
240
241
            return $result['ContentLength'];
242
        } catch (\Exception $e) {
243
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type declared by the interface Gaufrette\Adapter\SizeCalculator::size of type integer.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
244
        }
245
    }
246
247
    /**
248
     * {@inheritdoc}
249
     */
250
    public function keys()
251
    {
252
        return $this->listKeys();
253
    }
254
255
    /**
256
     * {@inheritdoc}
257
     */
258
    public function listKeys($prefix = '')
259
    {
260
        $this->ensureBucketExists();
261
262
        $options = ['Bucket' => $this->bucket];
263 View Code Duplication
        if ((string) $prefix != '') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
264
            $options['Prefix'] = $this->computePath($prefix);
265
        } elseif (!empty($this->options['directory'])) {
266
            $options['Prefix'] = $this->options['directory'];
267
        }
268
269
        $keys = [];
270
        $iter = $this->service->getIterator('ListObjects', $options);
271
        foreach ($iter as $file) {
272
            $keys[] = $this->computeKey($file['Key']);
273
        }
274
275
        return $keys;
276
    }
277
278
    /**
279
     * {@inheritdoc}
280
     */
281
    public function delete($key)
282
    {
283
        try {
284
            $this->service->deleteObject($this->getOptions($key));
285
286
            return true;
287
        } catch (\Exception $e) {
288
            return false;
289
        }
290
    }
291
292
    /**
293
     * {@inheritdoc}
294
     */
295
    public function isDirectory($key)
296
    {
297
        $result = $this->service->listObjects([
298
            'Bucket' => $this->bucket,
299
            'Prefix' => rtrim($this->computePath($key), '/') . '/',
300
            'MaxKeys' => 1,
301
        ]);
302
        if (isset($result['Contents'])) {
303
            if (is_array($result['Contents']) || $result['Contents'] instanceof \Countable) {
304
                return count($result['Contents']) > 0;
305
            }
306
        }
307
308
        return false;
309
    }
310
311
    /**
312
     * Ensures the specified bucket exists. If the bucket does not exists
313
     * and the create option is set to true, it will try to create the
314
     * bucket. The bucket is created using the same region as the supplied
315
     * client object.
316
     *
317
     * @throws \RuntimeException if the bucket does not exists or could not be
318
     *                           created
319
     */
320
    protected function ensureBucketExists()
321
    {
322
        if ($this->bucketExists) {
323
            return true;
324
        }
325
326
        if ($this->bucketExists = $this->service->doesBucketExist($this->bucket)) {
327
            return true;
328
        }
329
330
        if (!$this->options['create']) {
331
            throw new \RuntimeException(sprintf('The configured bucket "%s" does not exist.', $this->bucket));
332
        }
333
334
        $this->service->createBucket([
335
            'Bucket' => $this->bucket,
336
            'LocationConstraint' => $this->service->getRegion(),
337
        ]);
338
        $this->bucketExists = true;
339
340
        return true;
341
    }
342
343
    protected function getOptions($key, array $options = [])
344
    {
345
        $options['ACL'] = $this->options['acl'];
346
        $options['Bucket'] = $this->bucket;
347
        $options['Key'] = $this->computePath($key);
348
349
        /*
350
         * Merge global options for adapter, which are set in the constructor, with metadata.
351
         * Metadata will override global options.
352
         */
353
        $options = array_merge($this->options, $options, $this->getMetadata($key));
354
355
        return $options;
356
    }
357
358 View Code Duplication
    protected function computePath($key)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
359
    {
360
        if (empty($this->options['directory'])) {
361
            return $key;
362
        }
363
364
        return sprintf('%s/%s', $this->options['directory'], $key);
365
    }
366
367
    /**
368
     * MultiPart upload for big files (exceeding size_limit)
369
     *
370
     * @param string   $key
371
     * @param resource $content
372
     *
373
     * @return bool
374
     */
375
    protected function multipartUpload($key, $content)
376
    {
377
        $uploadId = $this->initiateMultipartUpload($key);
378
379
        $parts = [];
380
        $partNumber = 1;
381
382
        rewind($content);
383
384
        try {
385
            while (!feof($content)) {
386
                $result = $this->uploadNextPart($key, $content, $uploadId, $partNumber);
0 ignored issues
show
Documentation introduced by
$content is of type resource, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
387
                $parts[] = [
388
                    'PartNumber' => $partNumber++,
389
                    'ETag' => $result['ETag'],
390
                ];
391
            }
392
        } catch (S3Exception $e) {
393
            $this->abortMultipartUpload($key, $uploadId);
394
395
            return false;
396
        }
397
398
        $this->completeMultipartUpload($key, $uploadId, $parts);
399
400
        return true;
401
    }
402
403
    /**
404
     * @param string $key
405
     *
406
     * @return string
407
     */
408
    protected function initiateMultipartUpload($key)
409
    {
410
        $options = $this->getOptions($key);
411
        $result = $this->service->createMultipartUpload($options);
412
413
        return $result['UploadId'];
414
    }
415
416
    /**
417
     * @param string $key
418
     * @param string $content
419
     * @param string $uploadId
420
     * @param int    $partNumber
421
     *
422
     * @return \Aws\Result
423
     */
424
    protected function uploadNextPart($key, $content, $uploadId, $partNumber)
425
    {
426
        $options = $this->getOptions(
427
            $key,
428
            [
429
                'UploadId' => $uploadId,
430
                'PartNumber' => $partNumber,
431
                'Body' => fread($content, $this->partSize),
432
            ]
433
        );
434
435
        $options = $this->getOptions($key, $options);
436
437
        return $this->service->uploadPart($options);
438
    }
439
440
    /**
441
     * @param string $key
442
     * @param string $uploadId
443
     * @param array  $parts
444
     */
445
    protected function completeMultipartUpload($key, $uploadId, $parts)
446
    {
447
        $options = $this->getOptions(
448
           $key,
449
           [
450
               'UploadId' => $uploadId,
451
               'MultipartUpload' => ['Parts' => $parts],
452
           ]
453
        );
454
        $this->service->completeMultipartUpload($options);
455
    }
456
457
    /**
458
     * @param string $key
459
     * @param string $uploadId
460
     */
461
    protected function abortMultipartUpload($key, $uploadId)
462
    {
463
        $options = $this->getOptions($key, ['UploadId' => $uploadId]);
464
        $this->service->abortMultipartUpload($options);
465
    }
466
467
    /**
468
     * Computes the key from the specified path.
469
     *
470
     * @param string $path
471
     *
472
     * @return string
473
     */
474
    protected function computeKey($path)
475
    {
476
        return ltrim(substr($path, strlen($this->options['directory'])), '/');
477
    }
478
479
    /**
480
     * @param string $content
481
     *
482
     * @return string
483
     */
484 View Code Duplication
    private function guessContentType($content)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
485
    {
486
        $fileInfo = new \finfo(FILEINFO_MIME_TYPE);
487
488
        if (is_resource($content)) {
489
            return $fileInfo->file(stream_get_meta_data($content)['uri']);
490
        }
491
492
        return $fileInfo->buffer($content);
493
    }
494
495
    /**
496
     * @param string $key
497
     *
498
     * @return string
499
     */
500 View Code Duplication
    public function mimeType($key)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
501
    {
502
        try {
503
            $result = $this->service->headObject($this->getOptions($key));
504
505
            return $result['ContentType'];
506
        } catch (\Exception $e) {
507
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type declared by the interface Gaufrette\Adapter\MimeTypeProvider::mimeType of type string.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
508
        }
509
    }
510
}
511