Completed
Push — 2.x ( 741cb0...393be3 )
by Frank
01:26
created

AwsS3V3Adapter::fetchFileMetadata()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.6333
c 0
b 0
f 0
cc 3
nc 3
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\S3ClientInterface;
9
use Generator;
10
use League\Flysystem\Config;
11
use League\Flysystem\DirectoryAttributes;
12
use League\Flysystem\FileAttributes;
13
use League\Flysystem\FilesystemAdapter;
14
use League\Flysystem\FilesystemOperationFailed;
15
use League\Flysystem\PathPrefixer;
16
use League\Flysystem\StorageAttributes;
17
use League\Flysystem\UnableToCheckFileExistence;
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
    /**
96
     * @var bool
97
     */
98
    private $streamReads;
99
100
    public function __construct(
101
        S3ClientInterface $client,
102
        string $bucket,
103
        string $prefix = '',
104
        VisibilityConverter $visibility = null,
105
        MimeTypeDetector $mimeTypeDetector = null,
106
        array $options = [],
107
        bool $streamReads = true
108
    ) {
109
        $this->client = $client;
110
        $this->prefixer = new PathPrefixer($prefix);
111
        $this->bucket = $bucket;
112
        $this->visibility = $visibility ?: new PortableVisibilityConverter();
113
        $this->mimeTypeDetector = $mimeTypeDetector ?: new FinfoMimeTypeDetector();
114
        $this->options = $options;
115
        $this->streamReads = $streamReads;
116
    }
117
118
    public function fileExists(string $path): bool
119
    {
120
        try {
121
            return $this->client->doesObjectExist($this->bucket, $this->prefixer->prefixPath($path), $this->options);
122
        } catch (Throwable $exception) {
123
            throw UnableToCheckFileExistence::forLocation($path, $exception);
124
        }
125
    }
126
127
    public function write(string $path, string $contents, Config $config): void
128
    {
129
        $this->upload($path, $contents, $config);
130
    }
131
132
    /**
133
     * @param string          $path
134
     * @param string|resource $body
135
     * @param Config          $config
136
     */
137
    private function upload(string $path, $body, Config $config): void
138
    {
139
        $key = $this->prefixer->prefixPath($path);
140
        $acl = $this->determineAcl($config);
141
        $options = $this->createOptionsFromConfig($config);
142
        $shouldDetermineMimetype = $body !== '' && ! array_key_exists('ContentType', $options);
143
144
        if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) {
145
            $options['ContentType'] = $mimeType;
146
        }
147
148
        $this->client->upload($this->bucket, $key, $body, $acl, ['params' => $options]);
149
    }
150
151
    private function determineAcl(Config $config): string
152
    {
153
        $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE);
154
155
        return $this->visibility->visibilityToAcl($visibility);
156
    }
157
158
    private function createOptionsFromConfig(Config $config): array
159
    {
160
        $options = [];
161
162
        foreach (static::AVAILABLE_OPTIONS as $option) {
163
            $value = $config->get($option, '__NOT_SET__');
164
165
            if ($value !== '__NOT_SET__') {
166
                $options[$option] = $value;
167
            }
168
        }
169
170
        return $options + $this->options;
171
    }
172
173
    public function writeStream(string $path, $contents, Config $config): void
174
    {
175
        $this->upload($path, $contents, $config);
176
    }
177
178
    public function read(string $path): string
179
    {
180
        $body = $this->readObject($path, false);
181
182
        return (string) $body->getContents();
183
    }
184
185
    public function readStream(string $path)
186
    {
187
        /** @var resource $resource */
188
        $resource = $this->readObject($path, true)->detach();
189
190
        return $resource;
191
    }
192
193
    public function delete(string $path): void
194
    {
195
        $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
196
        $command = $this->client->getCommand('DeleteObject', $arguments);
197
198
        try {
199
            $this->client->execute($command);
200
        } catch (Throwable $exception) {
201
            throw UnableToDeleteFile::atLocation($path, '', $exception);
202
        }
203
    }
204
205
    public function deleteDirectory(string $path): void
206
    {
207
        $prefix = $this->prefixer->prefixPath($path);
208
        $prefix = ltrim(rtrim($prefix, '/') . '/', '/');
209
        $this->client->deleteMatchingObjects($this->bucket, $prefix);
210
    }
211
212
    public function createDirectory(string $path, Config $config): void
213
    {
214
        $config = $config->withDefaults(['visibility' => $this->visibility->defaultForDirectories()]);
215
        $this->upload(rtrim($path, '/') . '/', '', $config);
216
    }
217
218
    public function setVisibility(string $path, $visibility): void
219
    {
220
        $arguments = [
221
            'Bucket' => $this->bucket,
222
            'Key' => $this->prefixer->prefixPath($path),
223
            'ACL' => $this->visibility->visibilityToAcl($visibility),
224
        ];
225
        $command = $this->client->getCommand('PutObjectAcl', $arguments);
226
227
        try {
228
            $this->client->execute($command);
229
        } catch (Throwable $exception) {
230
            throw UnableToSetVisibility::atLocation($path, '', $exception);
231
        }
232
    }
233
234
    public function visibility(string $path): FileAttributes
235
    {
236
        $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
237
        $command = $this->client->getCommand('GetObjectAcl', $arguments);
238
239
        try {
240
            $result = $this->client->execute($command);
241
        } catch (Throwable $exception) {
242
            throw UnableToRetrieveMetadata::visibility($path, '', $exception);
243
        }
244
245
        $visibility = $this->visibility->aclToVisibility((array) $result->get('Grants'));
246
247
        return new FileAttributes($path, null, $visibility);
248
    }
249
250
    private function fetchFileMetadata(string $path, string $type): FileAttributes
251
    {
252
        $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
253
        $command = $this->client->getCommand('HeadObject', $arguments);
254
255
        try {
256
            $result = $this->client->execute($command);
257
        } catch (Throwable $exception) {
258
            throw UnableToRetrieveMetadata::create($path, $type, '', $exception);
259
        }
260
261
        $attributes = $this->mapS3ObjectMetadata($result->toArray(), $path);
262
263
        if ( ! $attributes instanceof FileAttributes) {
264
            throw UnableToRetrieveMetadata::create($path, $type, '');
265
        }
266
267
        return $attributes;
268
    }
269
270
    private function mapS3ObjectMetadata(array $metadata, string $path = null): StorageAttributes
271
    {
272
        if ($path === null) {
273
            $path = $this->prefixer->stripPrefix($metadata['Key'] ?? $metadata['Prefix']);
274
        }
275
276
        if (substr($path, -1) === '/') {
277
            return new DirectoryAttributes(rtrim($path, '/'));
278
        }
279
280
        $mimetype = $metadata['ContentType'] ?? null;
281
        $fileSize = $metadata['ContentLength'] ?? $metadata['Size'] ?? null;
282
        $fileSize = $fileSize === null ? null : (int) $fileSize;
283
        $dateTime = $metadata['LastModified'] ?? null;
284
        $lastModified = $dateTime instanceof DateTimeResult ? $dateTime->getTimeStamp() : null;
285
286
        return new FileAttributes(
287
            $path, $fileSize, null, $lastModified, $mimetype, $this->extractExtraMetadata($metadata)
288
        );
289
    }
290
291
    private function extractExtraMetadata(array $metadata): array
292
    {
293
        $extracted = [];
294
295
        foreach (static::EXTRA_METADATA_FIELDS as $field) {
296
            if (isset($metadata[$field]) && $metadata[$field] !== '') {
297
                $extracted[$field] = $metadata[$field];
298
            }
299
        }
300
301
        return $extracted;
302
    }
303
304
    public function mimeType(string $path): FileAttributes
305
    {
306
        return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE);
307
    }
308
309
    public function lastModified(string $path): FileAttributes
310
    {
311
        return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED);
312
    }
313
314
    public function fileSize(string $path): FileAttributes
315
    {
316
        return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE);
317
    }
318
319
    public function listContents(string $path, bool $deep): iterable
320
    {
321
        $prefix = trim($this->prefixer->prefixPath($path), '/');
322
        $prefix = empty($prefix) ? '' : $prefix . '/';
323
        $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix];
324
325
        if ($deep === false) {
326
            $options['Delimiter'] = '/';
327
        }
328
329
        $listing = $this->retrievePaginatedListing($options);
330
331
        foreach ($listing as $item) {
332
            yield $this->mapS3ObjectMetadata($item);
333
        }
334
    }
335
336
    private function retrievePaginatedListing(array $options): Generator
337
    {
338
        $resultPaginator = $this->client->getPaginator('ListObjects', $options + $this->options);
339
340
        foreach ($resultPaginator as $result) {
341
            yield from ($result->get('CommonPrefixes') ?: []);
342
            yield from ($result->get('Contents') ?: []);
343
        }
344
    }
345
346
    public function move(string $source, string $destination, Config $config): void
347
    {
348
        try {
349
            $this->copy($source, $destination, $config);
350
            $this->delete($source);
351
        } catch (FilesystemOperationFailed $exception) {
352
            throw UnableToMoveFile::fromLocationTo($source, $destination, $exception);
353
        }
354
    }
355
356
    public function copy(string $source, string $destination, Config $config): void
357
    {
358
        try {
359
            /** @var string $visibility */
360
            $visibility = $this->visibility($source)->visibility();
361
        } catch (Throwable $exception) {
362
            throw UnableToCopyFile::fromLocationTo(
363
                $source,
364
                $destination,
365
                $exception
366
            );
367
        }
368
369
        try {
370
            $this->client->copy(
371
                $this->bucket,
372
                $this->prefixer->prefixPath($source),
373
                $this->bucket,
374
                $this->prefixer->prefixPath($destination),
375
                $this->visibility->visibilityToAcl($visibility),
376
                $this->options
377
            );
378
        } catch (Throwable $exception) {
379
            throw UnableToCopyFile::fromLocationTo($source, $destination, $exception);
380
        }
381
    }
382
383
    private function readObject(string $path, bool $wantsStream): StreamInterface
384
    {
385
        $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)];
386
387
        if ($wantsStream && $this->streamReads && ! isset($this->options['@http']['stream'])) {
388
            $options['@http']['stream'] = true;
389
        }
390
391
        $command = $this->client->getCommand('GetObject', $options + $this->options);
392
393
        try {
394
            return $this->client->execute($command)->get('Body');
395
        } catch (Throwable $exception) {
396
            throw UnableToReadFile::fromLocation($path, '', $exception);
397
        }
398
    }
399
}
400