Completed
Push — 2.x ( caa3e0...1848cf )
by Frank
01:38
created

AwsS3V3Adapter::listContents()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 16
rs 9.7333
c 0
b 0
f 0
cc 4
nc 8
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace League\Flysystem\AwsS3V3;
6
7
use Aws\Api\DateTimeResult;
8
use Aws\S3\S3Client;
9
use Aws\S3\S3ClientInterface;
10
use Generator;
11
use League\Flysystem\Config;
12
use League\Flysystem\DirectoryAttributes;
13
use League\Flysystem\FileAttributes;
14
use League\Flysystem\FilesystemAdapter;
15
use League\Flysystem\FilesystemOperationFailed;
16
use League\Flysystem\PathPrefixer;
17
use League\Flysystem\StorageAttributes;
18
use League\Flysystem\UnableToCopyFile;
19
use League\Flysystem\UnableToDeleteFile;
20
use League\Flysystem\UnableToMoveFile;
21
use League\Flysystem\UnableToReadFile;
22
use League\Flysystem\UnableToRetrieveMetadata;
23
use League\Flysystem\UnableToSetVisibility;
24
use League\Flysystem\Visibility;
25
use League\MimeTypeDetection\FinfoMimeTypeDetector;
26
use League\MimeTypeDetection\MimeTypeDetector;
27
use Psr\Http\Message\StreamInterface;
28
use Throwable;
29
30
class AwsS3V3Adapter implements FilesystemAdapter
31
{
32
    /**
33
     * @var array
34
     */
35
    public const AVAILABLE_OPTIONS = [
36
        'ACL',
37
        'CacheControl',
38
        'ContentDisposition',
39
        'ContentEncoding',
40
        'ContentLength',
41
        'ContentType',
42
        'Expires',
43
        'GrantFullControl',
44
        'GrantRead',
45
        'GrantReadACP',
46
        'GrantWriteACP',
47
        'Metadata',
48
        'RequestPayer',
49
        'SSECustomerAlgorithm',
50
        'SSECustomerKey',
51
        'SSECustomerKeyMD5',
52
        'SSEKMSKeyId',
53
        'ServerSideEncryption',
54
        'StorageClass',
55
        'Tagging',
56
        'WebsiteRedirectLocation',
57
    ];
58
    private const EXTRA_METADATA_FIELDS = [
59
        'Metadata',
60
        'StorageClass',
61
        'ETag',
62
        'VersionId',
63
    ];
64
65
    /**
66
     * @var S3ClientInterface
67
     */
68
    private $client;
69
70
    /**
71
     * @var PathPrefixer
72
     */
73
    private $prefixer;
74
75
    /**
76
     * @var string
77
     */
78
    private $bucket;
79
80
    /**
81
     * @var VisibilityConverter
82
     */
83
    private $visibility;
84
85
    /**
86
     * @var MimeTypeDetector
87
     */
88
    private $mimeTypeDetector;
89
90
    /**
91
     * @var array
92
     */
93
    private $options;
94
95
    public function __construct(
96
        S3ClientInterface $client,
97
        string $bucket,
98
        string $prefix = '',
99
        VisibilityConverter $visibility = null,
100
        MimeTypeDetector $mimeTypeDetector = null,
101
        array $options = []
102
    ) {
103
        $this->client = $client;
104
        $this->prefixer = new PathPrefixer($prefix);
105
        $this->bucket = $bucket;
106
        $this->visibility = $visibility ?: new PortableVisibilityConverter();
107
        $this->mimeTypeDetector = $mimeTypeDetector ?: new FinfoMimeTypeDetector();
108
        $this->options = $options;
109
    }
110
111
    public function fileExists(string $path): bool
112
    {
113
        return $this->client->doesObjectExist($this->bucket, $this->prefixer->prefixPath($path), $this->options);
114
    }
115
116
    public function write(string $path, string $contents, Config $config): void
117
    {
118
        $this->upload($path, $contents, $config);
119
    }
120
121
    /**
122
     * @param string          $path
123
     * @param string|resource $body
124
     * @param Config          $config
125
     */
126
    private function upload(string $path, $body, Config $config): void
127
    {
128
        $key = $this->prefixer->prefixPath($path);
129
        $acl = $this->determineAcl($config);
130
        $options = $this->createOptionsFromConfig($config);
131
        $shouldDetermineMimetype = $body !== '' && ! array_key_exists('ContentType', $options);
132
133
        if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) {
134
            $options['ContentType'] = $mimeType;
135
        }
136
137
        $this->client->upload($this->bucket, $key, $body, $acl, ['params' => $options]);
138
    }
139
140
    private function determineAcl(Config $config): string
141
    {
142
        $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE);
143
144
        return $this->visibility->visibilityToAcl($visibility);
145
    }
146
147
    private function createOptionsFromConfig(Config $config): array
148
    {
149
        $options = [];
150
151
        foreach (static::AVAILABLE_OPTIONS as $option) {
152
            $value = $config->get($option, '__NOT_SET__');
153
154
            if ($value !== '__NOT_SET__') {
155
                $options[$option] = $value;
156
            }
157
        }
158
159
        return $options + $this->options;
160
    }
161
162
    public function writeStream(string $path, $contents, Config $config): void
163
    {
164
        $this->upload($path, $contents, $config);
165
    }
166
167
    public function read(string $path): string
168
    {
169
        $body = $this->readObject($path);
170
171
        return (string) $body->getContents();
172
    }
173
174
    public function readStream(string $path)
175
    {
176
        /** @var resource $resource */
177
        $resource = $this->readObject($path)->detach();
178
179
        return $resource;
180
    }
181
182
    public function delete(string $path): void
183
    {
184
        $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
185
        $command = $this->client->getCommand('DeleteObject', $arguments);
186
187
        try {
188
            $this->client->execute($command);
189
        } catch (Throwable $exception) {
190
            throw UnableToDeleteFile::atLocation($path, '', $exception);
191
        }
192
    }
193
194
    public function deleteDirectory(string $path): void
195
    {
196
        $prefix = $this->prefixer->prefixPath($path);
197
        $prefix = ltrim(rtrim($prefix, '/') . '/', '/');
198
        $this->client->deleteMatchingObjects($this->bucket, $prefix);
199
    }
200
201
    public function createDirectory(string $path, Config $config): void
202
    {
203
        $config = $config->withDefaults(['visibility' => $this->visibility->defaultForDirectories()]);
204
        $this->upload(rtrim($path, '/') . '/', '', $config);
205
    }
206
207
    public function setVisibility(string $path, $visibility): void
208
    {
209
        $arguments = [
210
            'Bucket' => $this->bucket,
211
            'Key'    => $this->prefixer->prefixPath($path),
212
            'ACL'    => $this->visibility->visibilityToAcl($visibility),
213
        ];
214
        $command = $this->client->getCommand('PutObjectAcl', $arguments);
215
216
        try {
217
            $this->client->execute($command);
218
        } catch (Throwable $exception) {
219
            throw UnableToSetVisibility::atLocation($path, '', $exception);
220
        }
221
    }
222
223
    public function visibility(string $path): FileAttributes
224
    {
225
        $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
226
        $command = $this->client->getCommand('GetObjectAcl', $arguments);
227
228
        try {
229
            $result = $this->client->execute($command);
230
        } catch (Throwable $exception) {
231
            throw UnableToRetrieveMetadata::visibility($path, '', $exception);
232
        }
233
234
        $visibility = $this->visibility->aclToVisibility((array) $result->get('Grants'));
235
236
        return new FileAttributes($path, null, $visibility);
237
    }
238
239
    private function fetchFileMetadata(string $path, string $type): FileAttributes
240
    {
241
        $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
242
        $command = $this->client->getCommand('HeadObject', $arguments);
243
244
        try {
245
            $result = $this->client->execute($command);
246
        } catch (Throwable $exception) {
247
            throw UnableToRetrieveMetadata::create($path, $type, '', $exception);
248
        }
249
250
        $attributes = $this->mapS3ObjectMetadata($result->toArray(), $path);
251
252
        if ( ! $attributes instanceof FileAttributes) {
253
            throw UnableToRetrieveMetadata::create($path, $type, '');
254
        }
255
256
        return $attributes;
257
    }
258
259
    private function mapS3ObjectMetadata(array $metadata, string $path = null): StorageAttributes
260
    {
261
        if ($path === null) {
262
            $path = $this->prefixer->stripPrefix($metadata['Key'] ?? $metadata['Prefix']);
263
        }
264
265
        if (substr($path, -1) === '/') {
266
            return new DirectoryAttributes(rtrim($path, '/'));
267
        }
268
269
        $mimetype = $metadata['ContentType'] ?? null;
270
        $fileSize = $metadata['ContentLength'] ?? $metadata['Size'] ?? null;
271
        $lastModified = null;
272
        $dateTime = $metadata['LastModified'] ?? null;
273
274
        if ($dateTime instanceof DateTimeResult) {
275
            $lastModified = $dateTime->getTimestamp();
276
        }
277
278
        return new FileAttributes(
279
            $path, (int) $fileSize, null, $lastModified, $mimetype, $this->extractExtraMetadata($metadata)
280
        );
281
    }
282
283
    private function extractExtraMetadata(array $metadata): array
284
    {
285
        $extracted = [];
286
287
        foreach (static::EXTRA_METADATA_FIELDS as $field) {
288
            if (isset($metadata[$field]) && $metadata[$field] !== '') {
289
                $extracted[$field] = $metadata[$field];
290
            }
291
        }
292
293
        return $extracted;
294
    }
295
296
    public function mimeType(string $path): FileAttributes
297
    {
298
        return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE);
299
    }
300
301
    public function lastModified(string $path): FileAttributes
302
    {
303
        return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED);
304
    }
305
306
    public function fileSize(string $path): FileAttributes
307
    {
308
        return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE);
309
    }
310
311
    public function listContents(string $path, bool $deep): iterable
312
    {
313
        $prefix = trim($this->prefixer->prefixPath($path), '/');
314
        $prefix = empty($prefix) ? '' : $prefix . '/';
315
        $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix];
316
317
        if ($deep === false) {
318
            $options['Delimiter'] = '/';
319
        }
320
321
        $listing = $this->retrievePaginatedListing($options);
322
323
        foreach ($listing as $item) {
324
            yield $this->mapS3ObjectMetadata($item);
325
        }
326
    }
327
328
    private function retrievePaginatedListing(array $options): Generator
329
    {
330
        $resultPaginator = $this->client->getPaginator('ListObjects', $options + $this->options);
331
332
        foreach ($resultPaginator as $result) {
333
            yield from ($result->get('CommonPrefixes') ?: []);
334
            yield from ($result->get('Contents') ?: []);
335
        }
336
    }
337
338
    public function move(string $source, string $destination, Config $config): void
339
    {
340
        try {
341
            $this->copy($source, $destination, $config);
342
            $this->delete($source);
343
        } catch (FilesystemOperationFailed $exception) {
344
            throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
345
        }
346
    }
347
348
    public function copy(string $source, string $destination, Config $config): void
349
    {
350
        try {
351
            /** @var string $visibility */
352
            $visibility = $this->visibility($source)->visibility();
353
        } catch (Throwable $exception) {
354
            throw UnableToCopyFile::fromLocationTo(
355
                $source,
356
                $destination,
357
                $exception
358
            );
359
        }
360
        $options = [
361
            'ACL'        => $this->visibility->visibilityToAcl($visibility),
362
            'Bucket'     => $this->bucket,
363
            'Key'        => $this->prefixer->prefixPath($destination),
364
            'CopySource' => S3Client::encodeKey($this->bucket . '/' . $this->prefixer->prefixPath($source)),
365
        ];
366
        $command = $this->client->getCommand('CopyObject', $options + $this->options);
367
368
        try {
369
            $this->client->execute($command);
370
        } catch (Throwable $exception) {
371
            throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
372
        }
373
    }
374
375
    private function readObject(string $path): StreamInterface
376
    {
377
        $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
378
        $command = $this->client->getCommand('GetObject', $options + $this->options);
379
380
        try {
381
            return $this->client->execute($command)->get('Body');
382
        } catch (Throwable $exception) {
383
            throw UnableToReadFile::fromLocation($path, '', $exception);
384
        }
385
    }
386
}
387