StreamWrapper::mkdir()   A
last analyzed

Complexity

Conditions 4
Paths 5

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 10
c 0
b 0
f 0
nc 5
nop 3
dl 0
loc 16
rs 9.9332
1
<?php
2
namespace Aws\S3;
3
4
use Aws\CacheInterface;
5
use Aws\LruArrayCache;
6
use Aws\Result;
7
use Aws\S3\Exception\S3Exception;
8
use GuzzleHttp\Psr7;
9
use GuzzleHttp\Psr7\Stream;
10
use GuzzleHttp\Psr7\CachingStream;
11
use Psr\Http\Message\StreamInterface;
12
13
/**
14
 * Amazon S3 stream wrapper to use "s3://<bucket>/<key>" files with PHP
15
 * streams, supporting "r", "w", "a", "x".
16
 *
17
 * # Opening "r" (read only) streams:
18
 *
19
 * Read only streams are truly streaming by default and will not allow you to
20
 * seek. This is because data read from the stream is not kept in memory or on
21
 * the local filesystem. You can force a "r" stream to be seekable by setting
22
 * the "seekable" stream context option true. This will allow true streaming of
23
 * data from Amazon S3, but will maintain a buffer of previously read bytes in
24
 * a 'php://temp' stream to allow seeking to previously read bytes from the
25
 * stream.
26
 *
27
 * You may pass any GetObject parameters as 's3' stream context options. These
28
 * options will affect how the data is downloaded from Amazon S3.
29
 *
30
 * # Opening "w" and "x" (write only) streams:
31
 *
32
 * Because Amazon S3 requires a Content-Length header, write only streams will
33
 * maintain a 'php://temp' stream to buffer data written to the stream until
34
 * the stream is flushed (usually by closing the stream with fclose).
35
 *
36
 * You may pass any PutObject parameters as 's3' stream context options. These
37
 * options will affect how the data is uploaded to Amazon S3.
38
 *
39
 * When opening an "x" stream, the file must exist on Amazon S3 for the stream
40
 * to open successfully.
41
 *
42
 * # Opening "a" (write only append) streams:
43
 *
44
 * Similar to "w" streams, opening append streams requires that the data be
45
 * buffered in a "php://temp" stream. Append streams will attempt to download
46
 * the contents of an object in Amazon S3, seek to the end of the object, then
47
 * allow you to append to the contents of the object. The data will then be
48
 * uploaded using a PutObject operation when the stream is flushed (usually
49
 * with fclose).
50
 *
51
 * You may pass any GetObject and/or PutObject parameters as 's3' stream
52
 * context options. These options will affect how the data is downloaded and
53
 * uploaded from Amazon S3.
54
 *
55
 * Stream context options:
56
 *
57
 * - "seekable": Set to true to create a seekable "r" (read only) stream by
58
 *   using a php://temp stream buffer
59
 * - For "unlink" only: Any option that can be passed to the DeleteObject
60
 *   operation
61
 */
62
class StreamWrapper
63
{
64
    /** @var resource|null Stream context (this is set by PHP) */
65
    public $context;
66
67
    /** @var StreamInterface Underlying stream resource */
68
    private $body;
69
70
    /** @var int Size of the body that is opened */
71
    private $size;
72
73
    /** @var array Hash of opened stream parameters */
74
    private $params = [];
75
76
    /** @var string Mode in which the stream was opened */
77
    private $mode;
78
79
    /** @var \Iterator Iterator used with opendir() related calls */
80
    private $objectIterator;
81
82
    /** @var string The bucket that was opened when opendir() was called */
83
    private $openedBucket;
84
85
    /** @var string The prefix of the bucket that was opened with opendir() */
86
    private $openedBucketPrefix;
87
88
    /** @var string Opened bucket path */
89
    private $openedPath;
90
91
    /** @var CacheInterface Cache for object and dir lookups */
92
    private $cache;
93
94
    /** @var string The opened protocol (e.g., "s3") */
95
    private $protocol = 's3';
96
97
    /**
98
     * Register the 's3://' stream wrapper
99
     *
100
     * @param S3ClientInterface $client   Client to use with the stream wrapper
101
     * @param string            $protocol Protocol to register as.
102
     * @param CacheInterface    $cache    Default cache for the protocol.
103
     */
104
    public static function register(
105
        S3ClientInterface $client,
106
        $protocol = 's3',
107
        CacheInterface $cache = null
108
    ) {
109
        if (in_array($protocol, stream_get_wrappers())) {
110
            stream_wrapper_unregister($protocol);
111
        }
112
113
        // Set the client passed in as the default stream context client
114
        stream_wrapper_register($protocol, get_called_class(), STREAM_IS_URL);
115
        $default = stream_context_get_options(stream_context_get_default());
116
        $default[$protocol]['client'] = $client;
117
118
        if ($cache) {
119
            $default[$protocol]['cache'] = $cache;
120
        } elseif (!isset($default[$protocol]['cache'])) {
121
            // Set a default cache adapter.
122
            $default[$protocol]['cache'] = new LruArrayCache();
123
        }
124
125
        stream_context_set_default($default);
126
    }
127
128
    public function stream_close()
129
    {
130
        $this->body = $this->cache = null;
131
    }
132
133
    public function stream_open($path, $mode, $options, &$opened_path)
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

133
    public function stream_open($path, $mode, /** @scrutinizer ignore-unused */ $options, &$opened_path)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $opened_path is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

133
    public function stream_open($path, $mode, $options, /** @scrutinizer ignore-unused */ &$opened_path)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
134
    {
135
        $this->initProtocol($path);
136
        $this->params = $this->getBucketKey($path);
137
        $this->mode = rtrim($mode, 'bt');
138
139
        if ($errors = $this->validate($path, $this->mode)) {
140
            return $this->triggerError($errors);
141
        }
142
143
        return $this->boolCall(function() use ($path) {
144
            switch ($this->mode) {
145
                case 'r': return $this->openReadStream($path);
0 ignored issues
show
Unused Code introduced by
The call to Aws\S3\StreamWrapper::openReadStream() has too many arguments starting with $path. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

145
                case 'r': return $this->/** @scrutinizer ignore-call */ openReadStream($path);

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. Please note the @ignore annotation hint above.

Loading history...
146
                case 'a': return $this->openAppendStream($path);
0 ignored issues
show
Unused Code introduced by
The call to Aws\S3\StreamWrapper::openAppendStream() has too many arguments starting with $path. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

146
                case 'a': return $this->/** @scrutinizer ignore-call */ openAppendStream($path);

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. Please note the @ignore annotation hint above.

Loading history...
147
                default: return $this->openWriteStream($path);
0 ignored issues
show
Unused Code introduced by
The call to Aws\S3\StreamWrapper::openWriteStream() has too many arguments starting with $path. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

147
                default: return $this->/** @scrutinizer ignore-call */ openWriteStream($path);

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. Please note the @ignore annotation hint above.

Loading history...
148
            }
149
        });
150
    }
151
152
    public function stream_eof()
153
    {
154
        return $this->body->eof();
155
    }
156
157
    public function stream_flush()
158
    {
159
        if ($this->mode == 'r') {
160
            return false;
161
        }
162
163
        if ($this->body->isSeekable()) {
164
            $this->body->seek(0);
165
        }
166
        $params = $this->getOptions(true);
167
        $params['Body'] = $this->body;
168
169
        // Attempt to guess the ContentType of the upload based on the
170
        // file extension of the key
171
        if (!isset($params['ContentType']) &&
172
            ($type = Psr7\mimetype_from_filename($params['Key']))
0 ignored issues
show
Bug introduced by
The function mimetype_from_filename was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

172
            ($type = /** @scrutinizer ignore-call */ Psr7\mimetype_from_filename($params['Key']))
Loading history...
173
        ) {
174
            $params['ContentType'] = $type;
175
        }
176
177
        $this->clearCacheKey("s3://{$params['Bucket']}/{$params['Key']}");
178
        return $this->boolCall(function () use ($params) {
179
            return (bool) $this->getClient()->putObject($params);
180
        });
181
    }
182
183
    public function stream_read($count)
184
    {
185
        return $this->body->read($count);
186
    }
187
188
    public function stream_seek($offset, $whence = SEEK_SET)
189
    {
190
        return !$this->body->isSeekable()
191
            ? false
192
            : $this->boolCall(function () use ($offset, $whence) {
193
                $this->body->seek($offset, $whence);
194
                return true;
195
            });
196
    }
197
198
    public function stream_tell()
199
    {
200
        return $this->boolCall(function() { return $this->body->tell(); });
201
    }
202
203
    public function stream_write($data)
204
    {
205
        return $this->body->write($data);
206
    }
207
208
    public function unlink($path)
209
    {
210
        $this->initProtocol($path);
211
212
        return $this->boolCall(function () use ($path) {
213
            $this->clearCacheKey($path);
214
            $this->getClient()->deleteObject($this->withPath($path));
215
            return true;
216
        });
217
    }
218
219
    public function stream_stat()
220
    {
221
        $stat = $this->getStatTemplate();
222
        $stat[7] = $stat['size'] = $this->getSize();
223
        $stat[2] = $stat['mode'] = $this->mode;
224
225
        return $stat;
226
    }
227
228
    /**
229
     * Provides information for is_dir, is_file, filesize, etc. Works on
230
     * buckets, keys, and prefixes.
231
     * @link http://www.php.net/manual/en/streamwrapper.url-stat.php
232
     */
233
    public function url_stat($path, $flags)
234
    {
235
        $this->initProtocol($path);
236
237
        // Some paths come through as S3:// for some reason.
238
        $split = explode('://', $path);
239
        $path = strtolower($split[0]) . '://' . $split[1];
240
241
        // Check if this path is in the url_stat cache
242
        if ($value = $this->getCacheStorage()->get($path)) {
243
            return $value;
244
        }
245
246
        $stat = $this->createStat($path, $flags);
247
248
        if (is_array($stat)) {
249
            $this->getCacheStorage()->set($path, $stat);
250
        }
251
252
        return $stat;
253
    }
254
255
    /**
256
     * Parse the protocol out of the given path.
257
     *
258
     * @param $path
259
     */
260
    private function initProtocol($path)
261
    {
262
        $parts = explode('://', $path, 2);
263
        $this->protocol = $parts[0] ?: 's3';
264
    }
265
266
    private function createStat($path, $flags)
267
    {
268
        $this->initProtocol($path);
269
        $parts = $this->withPath($path);
270
271
        if (!$parts['Key']) {
272
            return $this->statDirectory($parts, $path, $flags);
273
        }
274
275
        return $this->boolCall(function () use ($parts, $path) {
276
            try {
277
                $result = $this->getClient()->headObject($parts);
278
                if (substr($parts['Key'], -1, 1) == '/' &&
279
                    $result['ContentLength'] == 0
280
                ) {
281
                    // Return as if it is a bucket to account for console
282
                    // bucket objects (e.g., zero-byte object "foo/")
283
                    return $this->formatUrlStat($path);
284
                } else {
285
                    // Attempt to stat and cache regular object
286
                    return $this->formatUrlStat($result->toArray());
287
                }
288
            } catch (S3Exception $e) {
289
                // Maybe this isn't an actual key, but a prefix. Do a prefix
290
                // listing of objects to determine.
291
                $result = $this->getClient()->listObjects([
292
                    'Bucket'  => $parts['Bucket'],
293
                    'Prefix'  => rtrim($parts['Key'], '/') . '/',
294
                    'MaxKeys' => 1
295
                ]);
296
                if (!$result['Contents'] && !$result['CommonPrefixes']) {
297
                    throw new \Exception("File or directory not found: $path");
298
                }
299
                return $this->formatUrlStat($path);
300
            }
301
        }, $flags);
302
    }
303
304
    private function statDirectory($parts, $path, $flags)
305
    {
306
        // Stat "directories": buckets, or "s3://"
307
        if (!$parts['Bucket'] ||
308
            $this->getClient()->doesBucketExist($parts['Bucket'])
309
        ) {
310
            return $this->formatUrlStat($path);
311
        }
312
313
        return $this->triggerError("File or directory not found: $path", $flags);
314
    }
315
316
    /**
317
     * Support for mkdir().
318
     *
319
     * @param string $path    Directory which should be created.
320
     * @param int    $mode    Permissions. 700-range permissions map to
321
     *                        ACL_PUBLIC. 600-range permissions map to
322
     *                        ACL_AUTH_READ. All other permissions map to
323
     *                        ACL_PRIVATE. Expects octal form.
324
     * @param int    $options A bitwise mask of values, such as
325
     *                        STREAM_MKDIR_RECURSIVE.
326
     *
327
     * @return bool
328
     * @link http://www.php.net/manual/en/streamwrapper.mkdir.php
329
     */
330
    public function mkdir($path, $mode, $options)
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

330
    public function mkdir($path, $mode, /** @scrutinizer ignore-unused */ $options)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
331
    {
332
        $this->initProtocol($path);
333
        $params = $this->withPath($path);
334
        $this->clearCacheKey($path);
335
        if (!$params['Bucket']) {
336
            return false;
337
        }
338
339
        if (!isset($params['ACL'])) {
340
            $params['ACL'] = $this->determineAcl($mode);
341
        }
342
343
        return empty($params['Key'])
344
            ? $this->createBucket($path, $params)
345
            : $this->createSubfolder($path, $params);
346
    }
347
348
    public function rmdir($path, $options)
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

348
    public function rmdir($path, /** @scrutinizer ignore-unused */ $options)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
349
    {
350
        $this->initProtocol($path);
351
        $this->clearCacheKey($path);
352
        $params = $this->withPath($path);
353
        $client = $this->getClient();
354
355
        if (!$params['Bucket']) {
356
            return $this->triggerError('You must specify a bucket');
357
        }
358
359
        return $this->boolCall(function () use ($params, $path, $client) {
360
            if (!$params['Key']) {
361
                $client->deleteBucket(['Bucket' => $params['Bucket']]);
362
                return true;
363
            }
364
            return $this->deleteSubfolder($path, $params);
365
        });
366
    }
367
368
    /**
369
     * Support for opendir().
370
     *
371
     * The opendir() method of the Amazon S3 stream wrapper supports a stream
372
     * context option of "listFilter". listFilter must be a callable that
373
     * accepts an associative array of object data and returns true if the
374
     * object should be yielded when iterating the keys in a bucket.
375
     *
376
     * @param string $path    The path to the directory
377
     *                        (e.g. "s3://dir[</prefix>]")
378
     * @param string $options Unused option variable
379
     *
380
     * @return bool true on success
381
     * @see http://www.php.net/manual/en/function.opendir.php
382
     */
383
    public function dir_opendir($path, $options)
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

383
    public function dir_opendir($path, /** @scrutinizer ignore-unused */ $options)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
384
    {
385
        $this->initProtocol($path);
386
        $this->openedPath = $path;
387
        $params = $this->withPath($path);
388
        $delimiter = $this->getOption('delimiter');
389
        /** @var callable $filterFn */
390
        $filterFn = $this->getOption('listFilter');
391
        $op = ['Bucket' => $params['Bucket']];
392
        $this->openedBucket = $params['Bucket'];
393
394
        if ($delimiter === null) {
395
            $delimiter = '/';
396
        }
397
398
        if ($delimiter) {
399
            $op['Delimiter'] = $delimiter;
400
        }
401
402
        if ($params['Key']) {
403
            $params['Key'] = rtrim($params['Key'], $delimiter) . $delimiter;
404
            $op['Prefix'] = $params['Key'];
405
        }
406
407
        $this->openedBucketPrefix = $params['Key'];
408
409
        // Filter our "/" keys added by the console as directories, and ensure
410
        // that if a filter function is provided that it passes the filter.
411
        $this->objectIterator = \Aws\flatmap(
412
            $this->getClient()->getPaginator('ListObjects', $op),
413
            function (Result $result) use ($filterFn) {
414
                $contentsAndPrefixes = $result->search('[Contents[], CommonPrefixes[]][]');
415
                // Filter out dir place holder keys and use the filter fn.
416
                return array_filter(
417
                    $contentsAndPrefixes,
418
                    function ($key) use ($filterFn) {
419
                        return (!$filterFn || call_user_func($filterFn, $key))
420
                            && (!isset($key['Key']) || substr($key['Key'], -1, 1) !== '/');
421
                    }
422
                );
423
            }
424
        );
425
426
        return true;
427
    }
428
429
    /**
430
     * Close the directory listing handles
431
     *
432
     * @return bool true on success
433
     */
434
    public function dir_closedir()
435
    {
436
        $this->objectIterator = null;
437
        gc_collect_cycles();
438
439
        return true;
440
    }
441
442
    /**
443
     * This method is called in response to rewinddir()
444
     *
445
     * @return boolean true on success
446
     */
447
    public function dir_rewinddir()
448
    {
449
        $this->boolCall(function() {
450
            $this->objectIterator = null;
451
            $this->dir_opendir($this->openedPath, null);
452
            return true;
453
        });
454
    }
455
456
    /**
457
     * This method is called in response to readdir()
458
     *
459
     * @return string Should return a string representing the next filename, or
460
     *                false if there is no next file.
461
     * @link http://www.php.net/manual/en/function.readdir.php
462
     */
463
    public function dir_readdir()
464
    {
465
        // Skip empty result keys
466
        if (!$this->objectIterator->valid()) {
467
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
468
        }
469
470
        // First we need to create a cache key. This key is the full path to
471
        // then object in s3: protocol://bucket/key.
472
        // Next we need to create a result value. The result value is the
473
        // current value of the iterator without the opened bucket prefix to
474
        // emulate how readdir() works on directories.
475
        // The cache key and result value will depend on if this is a prefix
476
        // or a key.
477
        $cur = $this->objectIterator->current();
478
        if (isset($cur['Prefix'])) {
479
            // Include "directories". Be sure to strip a trailing "/"
480
            // on prefixes.
481
            $result = rtrim($cur['Prefix'], '/');
482
            $key = $this->formatKey($result);
483
            $stat = $this->formatUrlStat($key);
484
        } else {
485
            $result = $cur['Key'];
486
            $key = $this->formatKey($cur['Key']);
487
            $stat = $this->formatUrlStat($cur);
488
        }
489
490
        // Cache the object data for quick url_stat lookups used with
491
        // RecursiveDirectoryIterator.
492
        $this->getCacheStorage()->set($key, $stat);
493
        $this->objectIterator->next();
494
495
        // Remove the prefix from the result to emulate other stream wrappers.
496
        return $this->openedBucketPrefix
497
            ? substr($result, strlen($this->openedBucketPrefix))
498
            : $result;
499
    }
500
501
    private function formatKey($key)
502
    {
503
        $protocol = explode('://', $this->openedPath)[0];
504
        return "{$protocol}://{$this->openedBucket}/{$key}";
505
    }
506
507
    /**
508
     * Called in response to rename() to rename a file or directory. Currently
509
     * only supports renaming objects.
510
     *
511
     * @param string $path_from the path to the file to rename
512
     * @param string $path_to   the new path to the file
513
     *
514
     * @return bool true if file was successfully renamed
515
     * @link http://www.php.net/manual/en/function.rename.php
516
     */
517
    public function rename($path_from, $path_to)
518
    {
519
        // PHP will not allow rename across wrapper types, so we can safely
520
        // assume $path_from and $path_to have the same protocol
521
        $this->initProtocol($path_from);
522
        $partsFrom = $this->withPath($path_from);
523
        $partsTo = $this->withPath($path_to);
524
        $this->clearCacheKey($path_from);
525
        $this->clearCacheKey($path_to);
526
527
        if (!$partsFrom['Key'] || !$partsTo['Key']) {
528
            return $this->triggerError('The Amazon S3 stream wrapper only '
529
                . 'supports copying objects');
530
        }
531
532
        return $this->boolCall(function () use ($partsFrom, $partsTo) {
533
            $options = $this->getOptions(true);
534
            // Copy the object and allow overriding default parameters if
535
            // desired, but by default copy metadata
536
            $this->getClient()->copy(
537
                $partsFrom['Bucket'],
538
                $partsFrom['Key'],
539
                $partsTo['Bucket'],
540
                $partsTo['Key'],
541
                isset($options['acl']) ? $options['acl'] : 'private',
542
                $options
543
            );
544
            // Delete the original object
545
            $this->getClient()->deleteObject([
546
                'Bucket' => $partsFrom['Bucket'],
547
                'Key'    => $partsFrom['Key']
548
            ] + $options);
549
            return true;
550
        });
551
    }
552
553
    public function stream_cast($cast_as)
0 ignored issues
show
Unused Code introduced by
The parameter $cast_as is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

553
    public function stream_cast(/** @scrutinizer ignore-unused */ $cast_as)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
554
    {
555
        return false;
556
    }
557
558
    /**
559
     * Validates the provided stream arguments for fopen and returns an array
560
     * of errors.
561
     */
562
    private function validate($path, $mode)
563
    {
564
        $errors = [];
565
566
        if (!$this->getOption('Key')) {
567
            $errors[] = 'Cannot open a bucket. You must specify a path in the '
568
                . 'form of s3://bucket/key';
569
        }
570
571
        if (!in_array($mode, ['r', 'w', 'a', 'x'])) {
572
            $errors[] = "Mode not supported: {$mode}. "
573
                . "Use one 'r', 'w', 'a', or 'x'.";
574
        }
575
576
        // When using mode "x" validate if the file exists before attempting
577
        // to read
578
        if ($mode == 'x' &&
579
            $this->getClient()->doesObjectExist(
580
                $this->getOption('Bucket'),
581
                $this->getOption('Key'),
582
                $this->getOptions(true)
583
            )
584
        ) {
585
            $errors[] = "{$path} already exists on Amazon S3";
586
        }
587
588
        return $errors;
589
    }
590
591
    /**
592
     * Get the stream context options available to the current stream
593
     *
594
     * @param bool $removeContextData Set to true to remove contextual kvp's
595
     *                                like 'client' from the result.
596
     *
597
     * @return array
598
     */
599
    private function getOptions($removeContextData = false)
600
    {
601
        // Context is not set when doing things like stat
602
        if ($this->context === null) {
603
            $options = [];
604
        } else {
605
            $options = stream_context_get_options($this->context);
606
            $options = isset($options[$this->protocol])
607
                ? $options[$this->protocol]
608
                : [];
609
        }
610
611
        $default = stream_context_get_options(stream_context_get_default());
612
        $default = isset($default[$this->protocol])
613
            ? $default[$this->protocol]
614
            : [];
615
        $result = $this->params + $options + $default;
616
617
        if ($removeContextData) {
618
            unset($result['client'], $result['seekable'], $result['cache']);
619
        }
620
621
        return $result;
622
    }
623
624
    /**
625
     * Get a specific stream context option
626
     *
627
     * @param string $name Name of the option to retrieve
628
     *
629
     * @return mixed|null
630
     */
631
    private function getOption($name)
632
    {
633
        $options = $this->getOptions();
634
635
        return isset($options[$name]) ? $options[$name] : null;
636
    }
637
638
    /**
639
     * Gets the client from the stream context
640
     *
641
     * @return S3ClientInterface
642
     * @throws \RuntimeException if no client has been configured
643
     */
644
    private function getClient()
645
    {
646
        if (!$client = $this->getOption('client')) {
647
            throw new \RuntimeException('No client in stream context');
648
        }
649
650
        return $client;
651
    }
652
653
    private function getBucketKey($path)
654
    {
655
        // Remove the protocol
656
        $parts = explode('://', $path);
657
        // Get the bucket, key
658
        $parts = explode('/', $parts[1], 2);
659
660
        return [
661
            'Bucket' => $parts[0],
662
            'Key'    => isset($parts[1]) ? $parts[1] : null
663
        ];
664
    }
665
666
    /**
667
     * Get the bucket and key from the passed path (e.g. s3://bucket/key)
668
     *
669
     * @param string $path Path passed to the stream wrapper
670
     *
671
     * @return array Hash of 'Bucket', 'Key', and custom params from the context
672
     */
673
    private function withPath($path)
674
    {
675
        $params = $this->getOptions(true);
676
677
        return $this->getBucketKey($path) + $params;
678
    }
679
680
    private function openReadStream()
681
    {
682
        $client = $this->getClient();
683
        $command = $client->getCommand('GetObject', $this->getOptions(true));
684
        $command['@http']['stream'] = true;
685
        $result = $client->execute($command);
686
        $this->size = $result['ContentLength'];
687
        $this->body = $result['Body'];
688
689
        // Wrap the body in a caching entity body if seeking is allowed
690
        if ($this->getOption('seekable') && !$this->body->isSeekable()) {
691
            $this->body = new CachingStream($this->body);
692
        }
693
694
        return true;
695
    }
696
697
    private function openWriteStream()
698
    {
699
        $this->body = new Stream(fopen('php://temp', 'r+'));
700
        return true;
701
    }
702
703
    private function openAppendStream()
704
    {
705
        try {
706
            // Get the body of the object and seek to the end of the stream
707
            $client = $this->getClient();
708
            $this->body = $client->getObject($this->getOptions(true))['Body'];
709
            $this->body->seek(0, SEEK_END);
710
            return true;
711
        } catch (S3Exception $e) {
712
            // The object does not exist, so use a simple write stream
713
            return $this->openWriteStream();
714
        }
715
    }
716
717
    /**
718
     * Trigger one or more errors
719
     *
720
     * @param string|array $errors Errors to trigger
721
     * @param mixed        $flags  If set to STREAM_URL_STAT_QUIET, then no
722
     *                             error or exception occurs
723
     *
724
     * @return bool Returns false
725
     * @throws \RuntimeException if throw_errors is true
726
     */
727
    private function triggerError($errors, $flags = null)
728
    {
729
        // This is triggered with things like file_exists()
730
        if ($flags & STREAM_URL_STAT_QUIET) {
731
            return $flags & STREAM_URL_STAT_LINK
0 ignored issues
show
Bug Best Practice introduced by
The expression return $flags & Aws\S3\S...tUrlStat(false) : false also could return the type integer[] which is incompatible with the documented return type boolean.
Loading history...
732
                // This is triggered for things like is_link()
733
                ? $this->formatUrlStat(false)
0 ignored issues
show
Bug introduced by
false of type false is incompatible with the type array|string expected by parameter $result of Aws\S3\StreamWrapper::formatUrlStat(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

733
                ? $this->formatUrlStat(/** @scrutinizer ignore-type */ false)
Loading history...
734
                : false;
735
        }
736
737
        // This is triggered when doing things like lstat() or stat()
738
        trigger_error(implode("\n", (array) $errors), E_USER_WARNING);
739
740
        return false;
741
    }
742
743
    /**
744
     * Prepare a url_stat result array
745
     *
746
     * @param string|array $result Data to add
747
     *
748
     * @return array Returns the modified url_stat result
749
     */
750
    private function formatUrlStat($result = null)
751
    {
752
        $stat = $this->getStatTemplate();
753
        switch (gettype($result)) {
754
            case 'NULL':
755
            case 'string':
756
                // Directory with 0777 access - see "man 2 stat".
757
                $stat['mode'] = $stat[2] = 0040777;
758
                break;
759
            case 'array':
760
                // Regular file with 0777 access - see "man 2 stat".
761
                $stat['mode'] = $stat[2] = 0100777;
762
                // Pluck the content-length if available.
763
                if (isset($result['ContentLength'])) {
764
                    $stat['size'] = $stat[7] = $result['ContentLength'];
765
                } elseif (isset($result['Size'])) {
766
                    $stat['size'] = $stat[7] = $result['Size'];
767
                }
768
                if (isset($result['LastModified'])) {
769
                    // ListObjects or HeadObject result
770
                    $stat['mtime'] = $stat[9] = $stat['ctime'] = $stat[10]
771
                        = strtotime($result['LastModified']);
772
                }
773
        }
774
775
        return $stat;
776
    }
777
778
    /**
779
     * Creates a bucket for the given parameters.
780
     *
781
     * @param string $path   Stream wrapper path
782
     * @param array  $params A result of StreamWrapper::withPath()
783
     *
784
     * @return bool Returns true on success or false on failure
785
     */
786
    private function createBucket($path, array $params)
787
    {
788
        if ($this->getClient()->doesBucketExist($params['Bucket'])) {
789
            return $this->triggerError("Bucket already exists: {$path}");
790
        }
791
792
        return $this->boolCall(function () use ($params, $path) {
793
            $this->getClient()->createBucket($params);
794
            $this->clearCacheKey($path);
795
            return true;
796
        });
797
    }
798
799
    /**
800
     * Creates a pseudo-folder by creating an empty "/" suffixed key
801
     *
802
     * @param string $path   Stream wrapper path
803
     * @param array  $params A result of StreamWrapper::withPath()
804
     *
805
     * @return bool
806
     */
807
    private function createSubfolder($path, array $params)
808
    {
809
        // Ensure the path ends in "/" and the body is empty.
810
        $params['Key'] = rtrim($params['Key'], '/') . '/';
811
        $params['Body'] = '';
812
813
        // Fail if this pseudo directory key already exists
814
        if ($this->getClient()->doesObjectExist(
815
            $params['Bucket'],
816
            $params['Key'])
817
        ) {
818
            return $this->triggerError("Subfolder already exists: {$path}");
819
        }
820
821
        return $this->boolCall(function () use ($params, $path) {
822
            $this->getClient()->putObject($params);
823
            $this->clearCacheKey($path);
824
            return true;
825
        });
826
    }
827
828
    /**
829
     * Deletes a nested subfolder if it is empty.
830
     *
831
     * @param string $path   Path that is being deleted (e.g., 's3://a/b/c')
832
     * @param array  $params A result of StreamWrapper::withPath()
833
     *
834
     * @return bool
835
     */
836
    private function deleteSubfolder($path, $params)
837
    {
838
        // Use a key that adds a trailing slash if needed.
839
        $prefix = rtrim($params['Key'], '/') . '/';
840
        $result = $this->getClient()->listObjects([
841
            'Bucket'  => $params['Bucket'],
842
            'Prefix'  => $prefix,
843
            'MaxKeys' => 1
844
        ]);
845
846
        // Check if the bucket contains keys other than the placeholder
847
        if ($contents = $result['Contents']) {
848
            return (count($contents) > 1 || $contents[0]['Key'] != $prefix)
849
                ? $this->triggerError('Subfolder is not empty')
850
                : $this->unlink(rtrim($path, '/') . '/');
851
        }
852
853
        return $result['CommonPrefixes']
854
            ? $this->triggerError('Subfolder contains nested folders')
855
            : true;
856
    }
857
858
    /**
859
     * Determine the most appropriate ACL based on a file mode.
860
     *
861
     * @param int $mode File mode
862
     *
863
     * @return string
864
     */
865
    private function determineAcl($mode)
866
    {
867
        switch (substr(decoct($mode), 0, 1)) {
868
            case '7': return 'public-read';
869
            case '6': return 'authenticated-read';
870
            default: return 'private';
871
        }
872
    }
873
874
    /**
875
     * Gets a URL stat template with default values
876
     *
877
     * @return array
878
     */
879
    private function getStatTemplate()
880
    {
881
        return [
882
            0  => 0,  'dev'     => 0,
883
            1  => 0,  'ino'     => 0,
884
            2  => 0,  'mode'    => 0,
885
            3  => 0,  'nlink'   => 0,
886
            4  => 0,  'uid'     => 0,
887
            5  => 0,  'gid'     => 0,
888
            6  => -1, 'rdev'    => -1,
889
            7  => 0,  'size'    => 0,
890
            8  => 0,  'atime'   => 0,
891
            9  => 0,  'mtime'   => 0,
892
            10 => 0,  'ctime'   => 0,
893
            11 => -1, 'blksize' => -1,
894
            12 => -1, 'blocks'  => -1,
895
        ];
896
    }
897
898
    /**
899
     * Invokes a callable and triggers an error if an exception occurs while
900
     * calling the function.
901
     *
902
     * @param callable $fn
903
     * @param int      $flags
904
     *
905
     * @return bool
906
     */
907
    private function boolCall(callable $fn, $flags = null)
908
    {
909
        try {
910
            return $fn();
911
        } catch (\Exception $e) {
912
            return $this->triggerError($e->getMessage(), $flags);
913
        }
914
    }
915
916
    /**
917
     * @return LruArrayCache
918
     */
919
    private function getCacheStorage()
920
    {
921
        if (!$this->cache) {
922
            $this->cache = $this->getOption('cache') ?: new LruArrayCache();
923
        }
924
925
        return $this->cache;
926
    }
927
928
    /**
929
     * Clears a specific stat cache value from the stat cache and LRU cache.
930
     *
931
     * @param string $key S3 path (s3://bucket/key).
932
     */
933
    private function clearCacheKey($key)
934
    {
935
        clearstatcache(true, $key);
936
        $this->getCacheStorage()->remove($key);
937
    }
938
939
    /**
940
     * Returns the size of the opened object body.
941
     *
942
     * @return int|null
943
     */
944
    private function getSize()
945
    {
946
        $size = $this->body->getSize();
947
948
        return $size !== null ? $size : $this->size;
949
    }
950
}
951