Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.50% covered (success)
89.50%
196 / 219
51.52% covered (warning)
51.52%
17 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
S3
89.50% covered (success)
89.50%
196 / 219
51.52% covered (warning)
51.52%
17 / 33
121.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setBaseDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 withS3Prefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 setClient
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getClient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasClient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mkdir
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 rmdir
85.71% covered (success)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
7.14
 listDirs
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
15
 listFiles
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
13.02
 putFile
71.43% covered (success)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 putFileContents
50.00% covered (warning)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 putFileStream
77.78% covered (success)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 uploadFile
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 copyFile
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 copyFileToExternal
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 copyFileFromExternal
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 moveFileToExternal
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 moveFileFromExternal
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 renameFile
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 replaceFileContents
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 deleteFile
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 fetchFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetchFileStream
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetchFileInfo
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 getTemporaryUrl
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
2.35
 fileExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getFileMTime
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 md5File
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Storage\Adapter;
16
17use Aws\Exception\AwsException;
18use Aws\S3\S3Client;
19use Pop\Storage\Exception\DirectoryNotFoundException;
20use Pop\Storage\Exception\FileNotFoundException;
21use Pop\Storage\Exception\PathTraversalException;
22use Pop\Storage\Exception\UnableToCopyFileException;
23use Pop\Storage\Exception\UnableToCreateDirectoryException;
24use Pop\Storage\Exception\UnableToDeleteDirectoryException;
25use Pop\Storage\Exception\UnableToDeleteFileException;
26use Pop\Storage\Exception\UnableToGenerateTemporaryUrlException;
27use Pop\Storage\Exception\UnableToMoveFileException;
28use Pop\Storage\Exception\UnableToReadFileException;
29use Pop\Storage\Exception\UnableToWriteFileException;
30
31/**
32 * Storage adapter S3 class
33 *
34 * @category   Pop
35 * @package    Pop\Storage
36 * @author     Nick Sagona, III <nick@popphp.org>
37 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
38 * @license    https://www.popphp.org/license     New BSD License
39 * @version    3.0.0
40 */
41class S3 extends AbstractAdapter
42{
43
44    /**
45     * S3 client
46     * @var ?S3Client
47     */
48    protected ?S3Client $client = null;
49
50    /**
51     * Constructor
52     *
53     * @param string   $directory
54     * @param S3Client $client
55     */
56    public function __construct(string $directory, S3Client $client)
57    {
58        parent::__construct($directory);
59        $this->setClient($client);
60    }
61
62    /**
63     * Set base directory - normalizes the bucket to always carry the 's3://'
64     * prefix the stream wrapper requires, so callers can pass either
65     * 'my-bucket' or 's3://my-bucket'
66     *
67     * @param  ?string $directory
68     * @return void
69     */
70    public function setBaseDir(?string $directory = null): void
71    {
72        parent::setBaseDir(empty($directory) ? $directory : static::withS3Prefix($directory));
73    }
74
75    /**
76     * Prepend the 's3://' prefix the stream wrapper requires, if it isn't already there
77     *
78     * @param  string $path
79     * @return string
80     */
81    protected static function withS3Prefix(string $path): string
82    {
83        return str_starts_with($path, 's3://') ? $path : 's3://' . $path;
84    }
85
86    /**
87     * Set S3 client
88     *
89     * @param  S3Client $client
90     * @return S3
91     */
92    public function setClient(S3Client $client): S3
93    {
94        $this->client = $client;
95        $this->client->registerStreamWrapper();
96        return $this;
97    }
98
99    /**
100     * Get S3 client
101     *
102     * @return ?S3Client
103     */
104    public function getClient(): ?S3Client
105    {
106        return $this->client;
107    }
108
109    /**
110     * Has S3 client
111     *
112     * @return bool
113     */
114    public function hasClient(): bool
115    {
116        return ($this->client !== null);
117    }
118
119    /**
120     * Make directory
121     *
122     * @param  string $directory
123     * @return void
124     */
125    public function mkdir(string $directory): void
126    {
127        $key    = $this->scrub($directory) . '/';
128        $bucket = str_replace('s3://', '', $this->directory);
129        if (str_contains($bucket, '/')) {
130            $subfolder = substr($bucket, (strpos($bucket, '/') + 1));
131            $key       = $subfolder . '/' . $key;
132            $bucket    = substr($bucket, 0, strpos($bucket, '/'));
133        }
134
135        try {
136            $this->client->putObject(['Bucket' => $bucket, 'Key' => $key, 'Body' => '']);
137        } catch (AwsException $exception) {
138            throw new UnableToCreateDirectoryException(
139                'Error: Unable to create directory \'' . $directory . '\'.', 0, $exception
140            );
141        }
142    }
143
144    /**
145     * Remove a directory
146     *
147     * @param  string $directory
148     * @throws DirectoryNotFoundException
149     * @throws UnableToDeleteDirectoryException
150     * @return void
151     */
152    public function rmdir(string $directory): void
153    {
154        $path = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($directory);
155        if (!is_dir($path)) {
156            throw new DirectoryNotFoundException('Error: The directory \'' . $path . '\' was not found.');
157        }
158
159        $bucket = str_replace('s3://', '', $this->baseDirectory);
160        $prefix = str_replace($this->baseDirectory . '/', '', $path) . '/';
161
162        try {
163            $keys = [];
164            foreach ($this->client->getPaginator('ListObjects', ['Bucket' => $bucket, 'Prefix' => $prefix]) as $page) {
165                foreach ($page['Contents'] ?? [] as $object) {
166                    $keys[] = ['Key' => $object['Key']];
167                }
168            }
169
170            // Batch the deletes via S3's DeleteObjects API (up to 1000 keys per request)
171            // instead of one DeleteObject round trip per file.
172            foreach (array_chunk($keys, 1000) as $batch) {
173                $result = $this->client->deleteObjects([
174                    'Bucket' => $bucket,
175                    'Delete' => ['Objects' => $batch, 'Quiet' => true],
176                ]);
177                if (!empty($result['Errors'])) {
178                    throw new UnableToDeleteDirectoryException('Error: Unable to delete directory \'' . $path . '\'.');
179                }
180            }
181        } catch (AwsException $exception) {
182            throw new UnableToDeleteDirectoryException(
183                'Error: Unable to delete directory \'' . $path . '\'.', 0, $exception
184            );
185        }
186
187        // deleteObjects() is a raw SDK call that bypasses the stream wrapper, so it never
188        // invalidates the wrapper's own url_stat() cache - the is_dir() check above may have
189        // just warmed a stale "exists" entry for $path. unlink() clears that cache entry
190        // unconditionally before its own (here redundant, harmless - S3 doesn't error
191        // deleting an already-gone key) DeleteObject call.
192        @unlink($path);
193    }
194
195    /**
196     * List directories
197     *
198     * @param  ?string $search
199     * @param  bool    $recursive
200     * @return array
201     */
202    public function listDirs(?string $search = null, bool $recursive = false): array
203    {
204        $dirs   = [];
205        $params = ['Bucket' => str_replace('s3://', '', $this->baseDirectory)];
206        if (!$recursive) {
207            $params['Delimiter'] = '/';
208        }
209        if ($this->baseDirectory != $this->directory) {
210            $params['Prefix'] = str_replace($this->baseDirectory . '/', '', $this->directory . '/');
211        }
212
213        try {
214            foreach ($this->client->getPaginator('ListObjects', $params) as $page) {
215                foreach ($page['CommonPrefixes'] ?? [] as $commonPrefix) {
216                    $dirs[] = (isset($params['Prefix']) && str_starts_with($commonPrefix['Prefix'], $params['Prefix'])) ?
217                        substr($commonPrefix['Prefix'], strlen($params['Prefix'])) : $commonPrefix['Prefix'];
218                }
219                if ($recursive) {
220                    // Without Delimiter, S3 never returns CommonPrefixes at all - every
221                    // intermediate directory (whether it holds an explicit zero-byte marker
222                    // or only real files) has to be derived from each object key's own path
223                    // segments instead, the same way Local's recursive walk reports a
224                    // directory just because something lives in it.
225                    foreach ($page['Contents'] ?? [] as $object) {
226                        $relativeKey = (isset($params['Prefix']) && str_starts_with($object['Key'], $params['Prefix'])) ?
227                            substr($object['Key'], strlen($params['Prefix'])) : $object['Key'];
228                        $segments = explode('/', rtrim($relativeKey, '/'));
229                        array_pop($segments); // the object's own name/marker, not a parent directory
230                        $path = '';
231                        foreach ($segments as $segment) {
232                            if ($segment === '') {
233                                continue;
234                            }
235                            $path  .= $segment . '/';
236                            $dirs[] = $path;
237                        }
238                    }
239                }
240            }
241        } catch (AwsException $exception) {
242            throw new UnableToReadFileException('Error: Unable to list directories.', 0, $exception);
243        }
244
245        $dirs = array_values(array_unique($dirs));
246
247        if ($search !== null) {
248            $dirs = $this->searchFilter($dirs, $search);
249        }
250
251        return $dirs;
252    }
253
254    /**
255     * List files
256     *
257     * @param  ?string $search
258     * @param  bool    $recursive
259     * @return array
260     */
261    public function listFiles(?string $search = null, bool $recursive = false): array
262    {
263        $files  = [];
264        $params = ['Bucket' => str_replace('s3://', '', $this->baseDirectory)];
265        if (!$recursive) {
266            $params['Delimiter'] = '/';
267        }
268        if ($this->baseDirectory != $this->directory) {
269            $params['Prefix'] = str_replace($this->baseDirectory . '/', '', $this->directory . '/');
270        }
271
272        try {
273            foreach ($this->client->getPaginator('ListObjects', $params) as $page) {
274                foreach ($page['Contents'] ?? [] as $object) {
275                    $isDirectoryMarker = str_ends_with($object['Key'], '/') && (int) $object['Size'] === 0;
276                    $isCurrentPrefix   = isset($params['Prefix']) && ($object['Key'] === $params['Prefix']);
277                    if ($isDirectoryMarker || $isCurrentPrefix) {
278                        continue;
279                    }
280                    $files[] = (isset($params['Prefix']) && str_starts_with($object['Key'], $params['Prefix'])) ?
281                        substr($object['Key'], strlen($params['Prefix'])) : $object['Key'];
282                }
283            }
284        } catch (AwsException $exception) {
285            throw new UnableToReadFileException('Error: Unable to list files.', 0, $exception);
286        }
287
288        if ($search !== null) {
289            $files = $this->searchFilter($files, $search);
290        }
291
292        return $files;
293    }
294
295    /**
296     * Put file
297     *
298     * @param  string $fileFrom
299     * @param  bool   $copy
300     * @return void
301     */
302    public function putFile(string $fileFrom, bool $copy = true): void
303    {
304        if (!file_exists($fileFrom)) {
305            throw new FileNotFoundException('Error: The file \'' . $fileFrom . '\' was not found.');
306        }
307
308        $destination = $this->directory . DIRECTORY_SEPARATOR . basename($fileFrom);
309
310        if (!@copy($fileFrom, $destination)) {
311            throw new UnableToWriteFileException('Error: Unable to write file \'' . $fileFrom . '\'.');
312        }
313
314        // $copy = false means "move": PHP's rename() cannot bridge different stream wrapper
315        // protocols (a plain local path has none, the destination is s3://), so it can never
316        // succeed here the way it does for Local's same-wrapper rename() - copy the object,
317        // then remove the local source, to get the same move semantics.
318        if (!$copy && !@unlink($fileFrom)) {
319            throw new UnableToWriteFileException('Error: Unable to write file \'' . $fileFrom . '\'.');
320        }
321    }
322
323    /**
324     * Put file contents
325     *
326     * @param  string $filename
327     * @param  string $fileContents
328     * @return void
329     */
330    public function putFileContents(string $filename, string $fileContents): void
331    {
332        if (@file_put_contents($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename), $fileContents) === false) {
333            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
334        }
335    }
336
337    /**
338     * Put file from a stream resource
339     *
340     * @param  string $filename
341     * @param  mixed  $resource
342     * @throws UnableToWriteFileException
343     * @return void
344     */
345    public function putFileStream(string $filename, mixed $resource): void
346    {
347        if (!is_resource($resource)) {
348            throw new UnableToWriteFileException('Error: The provided resource is not a valid stream.');
349        }
350
351        $destination = @fopen($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename), 'w');
352        if ($destination === false) {
353            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
354        }
355
356        $copied = stream_copy_to_stream($resource, $destination);
357        fclose($destination);
358
359        if ($copied === false) {
360            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
361        }
362    }
363
364    /**
365     * Upload file from server request $_FILES['file']
366     *
367     * @param  array $file
368     * @throws UnableToWriteFileException|PathTraversalException
369     * @return void
370     */
371    public function uploadFile(array $file): void
372    {
373        if (!isset($file['tmp_name']) || !isset($file['name'])) {
374            throw new UnableToWriteFileException('Error: The uploaded file array was not valid.');
375        }
376
377        $contents = @file_get_contents($file['tmp_name']);
378        if ($contents === false) {
379            throw new UnableToWriteFileException('Error: Unable to write file \'' . $file['name'] . '\'.');
380        }
381
382        $destination = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($file['name']);
383        if (@file_put_contents($destination, $contents) === false) {
384            throw new UnableToWriteFileException('Error: Unable to write file \'' . $file['name'] . '\'.');
385        }
386    }
387
388    /**
389     * Copy file
390     *
391     * @param  string $sourceFile
392     * @param  string $destFile
393     * @return void
394     */
395    public function copyFile(string $sourceFile, string $destFile): void
396    {
397        $sourceFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($sourceFile);
398        $destFile   = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($destFile);
399        if (!file_exists($sourceFile)) {
400            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
401        }
402        if (!@copy($sourceFile, $destFile)) {
403            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $sourceFile . '\'.');
404        }
405    }
406
407    /**
408     * Copy file to a location external to the current location
409     *
410     * @param  string $sourceFile
411     * @param  string $externalFile
412     * @return void
413     */
414    public function copyFileToExternal(string $sourceFile, string $externalFile): void
415    {
416        $sourceFile   = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($sourceFile);
417        $externalFile = static::withS3Prefix($externalFile);
418        if (!file_exists($sourceFile)) {
419            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
420        }
421        if (!@copy($sourceFile, $externalFile)) {
422            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $sourceFile . '\'.');
423        }
424    }
425
426    /**
427     * Copy file from a location external to the current location
428     *
429     * @param  string $externalFile
430     * @param  string $destFile
431     * @return void
432     */
433    public function copyFileFromExternal(string $externalFile, string $destFile): void
434    {
435        $destFile     = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($destFile);
436        $externalFile = static::withS3Prefix($externalFile);
437        if (!file_exists($externalFile)) {
438            throw new FileNotFoundException('Error: The file \'' . $externalFile . '\' was not found.');
439        }
440        if (!@copy($externalFile, $destFile)) {
441            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $externalFile . '\'.');
442        }
443    }
444
445    /**
446     * Move file to a location external to the current location
447     *
448     * @param  string $sourceFile
449     * @param  string $externalFile
450     * @return void
451     */
452    public function moveFileToExternal(string $sourceFile, string $externalFile): void
453    {
454        $sourceFile   = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($sourceFile);
455        $externalFile = static::withS3Prefix($externalFile);
456        if (!file_exists($sourceFile)) {
457            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
458        }
459        if (!@rename($sourceFile, $externalFile)) {
460            throw new UnableToMoveFileException('Error: Unable to move file \'' . $sourceFile . '\'.');
461        }
462    }
463
464    /**
465     * Move file from a location external to the current location
466     *
467     * @param  string $externalFile
468     * @param  string $destFile
469     * @return void
470     */
471    public function moveFileFromExternal(string $externalFile, string $destFile): void
472    {
473        $destFile     = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($destFile);
474        $externalFile = static::withS3Prefix($externalFile);
475        if (!file_exists($externalFile)) {
476            throw new FileNotFoundException('Error: The file \'' . $externalFile . '\' was not found.');
477        }
478        if (!@rename($externalFile, $destFile)) {
479            throw new UnableToMoveFileException('Error: Unable to move file \'' . $externalFile . '\'.');
480        }
481    }
482
483    /**
484     * Rename file
485     *
486     * @param  string $oldFile
487     * @param  string $newFile
488     * @return void
489     */
490    public function renameFile(string $oldFile, string $newFile): void
491    {
492        $oldFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($oldFile);
493        $newFile = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($newFile);
494        if (!file_exists($oldFile)) {
495            throw new FileNotFoundException('Error: The file \'' . $oldFile . '\' was not found.');
496        }
497        if (!@rename($oldFile, $newFile)) {
498            throw new UnableToMoveFileException('Error: Unable to move file \'' . $oldFile . '\'.');
499        }
500    }
501
502    /**
503     * Replace file
504     *
505     * @param  string $filename
506     * @param  string $fileContents
507     * @return void
508     */
509    public function replaceFileContents(string $filename, string $fileContents): void
510    {
511        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
512        if (!file_exists($filename)) {
513            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
514        }
515        if (@file_put_contents($filename, $fileContents) === false) {
516            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\'.');
517        }
518    }
519
520    /**
521     * Delete file
522     *
523     * @param  string $filename
524     * @return void
525     */
526    public function deleteFile(string $filename): void
527    {
528        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
529        if (!file_exists($filename)) {
530            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
531        }
532        if (!@unlink($filename)) {
533            throw new UnableToDeleteFileException('Error: Unable to delete file \'' . $filename . '\'.');
534        }
535    }
536
537    /**
538     * Fetch file
539     *
540     * @param  string $filename
541     * @return mixed
542     */
543    public function fetchFile(string $filename): mixed
544    {
545        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
546        if (!file_exists($filename)) {
547            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
548        }
549        return file_get_contents($filename);
550    }
551
552    /**
553     * Fetch file as a stream resource
554     *
555     * @param  string $filename
556     * @throws FileNotFoundException
557     * @return mixed
558     */
559    public function fetchFileStream(string $filename): mixed
560    {
561        $path = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
562        if (!file_exists($path)) {
563            throw new FileNotFoundException('Error: The file \'' . $path . '\' was not found.');
564        }
565
566        return fopen($path, 'r');
567    }
568
569    /**
570     * Fetch file info
571     *
572     * @param  string $filename
573     * @return array
574     */
575    public function fetchFileInfo(string $filename): array
576    {
577        $path = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
578        if (!file_exists($path)) {
579            throw new FileNotFoundException('Error: The file \'' . $path . '\' was not found.');
580        }
581
582        try {
583            $fileObject = $this->client->headObject([
584                'Bucket' => str_replace('s3://', '', $this->directory),
585                'Key'    => $this->scrub($filename),
586            ]);
587            return $fileObject->toArray();
588        } catch (AwsException $exception) {
589            throw new UnableToReadFileException(
590                'Error: Unable to read file info for \'' . $filename . '\'.', 0, $exception
591            );
592        }
593    }
594
595    /**
596     * Get a temporary (presigned) URL for the file, valid for $expiresInSeconds
597     *
598     * @param  string $filename
599     * @param  int    $expiresInSeconds
600     * @throws UnableToGenerateTemporaryUrlException
601     * @return string
602     */
603    public function getTemporaryUrl(string $filename, int $expiresInSeconds = 900): string
604    {
605        $bucket = str_replace('s3://', '', $this->baseDirectory);
606        $key    = str_replace($this->baseDirectory . '/', '', $this->directory . '/') . $this->scrub($filename);
607
608        try {
609            $command = $this->client->getCommand('GetObject', ['Bucket' => $bucket, 'Key' => $key]);
610            $request = $this->client->createPresignedRequest($command, '+' . $expiresInSeconds . ' seconds');
611            return (string) $request->getUri();
612        } catch (AwsException $exception) {
613            throw new UnableToGenerateTemporaryUrlException(
614                'Error: Unable to generate a temporary URL for \'' . $filename . '\'.', 0, $exception
615            );
616        }
617    }
618
619    /**
620     * File exists
621     *
622     * @param  string $filename
623     * @return bool
624     */
625    public function fileExists(string $filename): bool
626    {
627        return file_exists($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename));
628    }
629
630    /**
631     * Check if is a dir
632     *
633     * @param  string $directory
634     * @return bool
635     */
636    public function isDir(string $directory): bool
637    {
638        return is_dir($this->directory . DIRECTORY_SEPARATOR . $this->scrub($directory));
639    }
640
641    /**
642     * Check if is a file
643     *
644     * @param  string $filename
645     * @return bool
646     */
647    public function isFile(string $filename): bool
648    {
649        return is_file($this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename));
650    }
651
652    /**
653     * Get file size
654     *
655     * @param  string $filename
656     * @throws FileNotFoundException
657     * @return int
658     */
659    public function getFileSize(string $filename): int
660    {
661        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
662        if (!file_exists($filename)) {
663            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
664        }
665        return filesize($filename);
666    }
667
668    /**
669     * Get file type
670     *
671     * @param  string $filename
672     * @throws FileNotFoundException
673     * @return string
674     */
675    public function getFileType(string $filename): string
676    {
677        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
678        if (!file_exists($filename)) {
679            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
680        }
681        return filetype($filename);
682    }
683
684    /**
685     * Get file modified time
686     *
687     * @param  string $filename
688     * @throws FileNotFoundException
689     * @return int|string
690     */
691    public function getFileMTime(string $filename): int|string
692    {
693        $filename = $this->directory . DIRECTORY_SEPARATOR . $this->scrub($filename);
694        if (!file_exists($filename)) {
695            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
696        }
697        return filemtime($filename);
698    }
699
700    /**
701     * Create MD5 checksum of the file
702     *
703     * @param  string $filename
704     * @throws FileNotFoundException
705     * @return string
706     */
707    public function md5File(string $filename): string
708    {
709        // HeadObject returns the same ETag as GetObject without transferring the object
710        // body, which matters a great deal on large files.
711        try {
712            $fileObject = $this->client->headObject([
713                'Bucket' => str_replace('s3://', '', $this->baseDirectory),
714                'Key'    => str_replace($this->baseDirectory . '/', '', $this->directory . '/') . $this->scrub($filename),
715            ]);
716        } catch (AwsException $exception) {
717            if ($exception->getStatusCode() === 404) {
718                throw new FileNotFoundException(
719                    'Error: The file \'' . $filename . '\' was not found.', 0, $exception
720                );
721            }
722            throw new UnableToReadFileException(
723                'Error: Unable to read file \'' . $filename . '\'.', 0, $exception
724            );
725        }
726
727        if (!isset($fileObject['ETag'])) {
728            throw new UnableToReadFileException('Error: No ETag/checksum returned for \'' . $filename . '\'.');
729        }
730
731        return str_replace('"', '', $fileObject['ETag']);
732    }
733
734}