Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.47% covered (success)
98.47%
321 / 326
95.35% covered (success)
95.35%
41 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
Azure
98.47% covered (success)
98.47%
321 / 326
95.35% covered (success)
95.35%
41 / 43
125
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initClient
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 setClient
100.00% covered (success)
100.00%
2 / 2
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
 getClientRequest
75.00% covered (success)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setAuth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setHandler
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 currentPrefix
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveUri
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 mkdir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rmdir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 listDirs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 listFiles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 walkBlobs
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
20
 putFile
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 putFileContents
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 putFileStream
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 uploadFile
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 copyFile
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 copyFileToExternal
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 copyFileFromExternal
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 moveFileToExternal
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 moveFileFromExternal
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 renameFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 replaceFileContents
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 deleteFile
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 fetchFile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 fetchFileStream
76.47% covered (success)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
4.21
 fetchFileInfo
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getTemporaryUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fileExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isDir
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 isFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getFileSize
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getFileType
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 getFileMTime
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 md5File
100.00% covered (success)
100.00%
8 / 8
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 Pop\Storage\Adapter\Azure\Auth;
18use Pop\Http\Body;
19use Pop\Http\Client;
20use Pop\Http\Client\Handler\HandlerInterface;
21use Pop\Http\Client\Request;
22use Pop\Storage\Exception\FileNotFoundException;
23use Pop\Storage\Exception\PathTraversalException;
24use Pop\Storage\Exception\UnableToCopyFileException;
25use Pop\Storage\Exception\UnableToDeleteFileException;
26use Pop\Storage\Exception\UnableToMoveFileException;
27use Pop\Storage\Exception\UnableToReadFileException;
28use Pop\Storage\Exception\UnableToWriteFileException;
29use Pop\Utils\File;
30
31/**
32 * Storage adapter Azure 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 Azure extends AbstractAdapter
42{
43
44    /**
45     * Maximum number of pages walkBlobs() will follow via NextMarker before giving up.
46     * A generous-but-finite backstop against a malformed or repeating continuation marker
47     * causing an infinite loop. Overridable in subclasses (e.g. for fast tests).
48     * @var int
49     */
50    protected const int MAX_LIST_PAGES = 10000;
51
52    /**
53     * Fallback Content-Type sent when the file/blob name has no extension File::getFileMimeType()
54     * can resolve (it returns null in that case) - required because the mime type feeds directly
55     * into a signed request header, and a null header value breaks request signing with a raw
56     * TypeError rather than a clean exception. 'application/octet-stream' is the standard
57     * "unknown binary content" MIME type.
58     * @var string
59     */
60    protected const string DEFAULT_CONTENT_TYPE = 'application/octet-stream';
61
62    /**
63     * HTTP client
64     * @var ?Client
65     */
66    protected ?Client $client = null;
67
68    /**
69     * Azure auth object
70     * @var ?Auth
71     */
72    protected ?Auth $auth = null;
73
74    /**
75     * HTTP client handler, injected for testing (defaults to the real Curl handler when null)
76     * @var ?HandlerInterface
77     */
78    protected ?HandlerInterface $handler = null;
79
80    /**
81     * Constructor
82     *
83     * @param string $location
84     * @param Auth   $auth
85     */
86    public function __construct(string $location, Auth $auth)
87    {
88        parent::__construct($location);
89        $this->setAuth($auth);
90        $this->initClient();
91    }
92
93    /**
94     * Create Azure client
95     *
96     * @param  string $accountName
97     * @param  string $accountKey
98     * @return Azure
99     */
100    public static function create(string $accountName, string $accountKey): Azure
101    {
102        return new self($accountName, new Azure\Auth($accountName, $accountKey));
103    }
104
105    /**
106     * Initialize client
107     *
108     * @param  string $method
109     * @param  array  $headers
110     * @param  bool   $auto
111     * @return Azure
112     */
113    public function initClient(string $method = 'GET', array $headers = [], bool $auto = true): Azure
114    {
115        $request = new Request('/', $method);
116        $request->addHeader('Date', gmdate('D, d M Y H:i:s T'))
117            ->addHeader('Host', $this->auth->getAccountName() . '.blob.core.windows.net')
118            ->addHeader('Content-Type', Client\Request::URLENCODED)
119            ->addHeader('User-Agent', 'pop-storage/3.0.0 (PHP ' . PHP_VERSION . ')/' . PHP_OS)
120            ->addHeader('x-ms-client-request-id', uniqid())
121            ->addHeader('x-ms-version', '2025-01-05');
122
123        if (!empty($headers)) {
124            foreach ($headers as $header => $value) {
125                $request->addHeader($header, $value);
126            }
127        }
128
129        $this->setClient(new Client(
130            $request, [
131                'base_uri' => $this->auth->getBaseUri(),
132                'auto'     => $auto
133            ]
134        ));
135
136        if ($this->handler !== null) {
137            $this->client->setHandler($this->handler);
138        }
139
140        return $this;
141    }
142
143    /**
144     * Set client
145     *
146     * @param  Client $client
147     * @return Azure
148     */
149    public function setClient(Client $client): Azure
150    {
151        $this->client = $client;
152        return $this;
153    }
154
155    /**
156     * Get client
157     *
158     * @return ?Client
159     */
160    public function getClient(): ?Client
161    {
162        return $this->client;
163    }
164
165    /**
166     * Has client
167     *
168     * @return bool
169     */
170    public function hasClient(): bool
171    {
172        return ($this->client !== null);
173    }
174
175    /**
176     * Get the current client's request, narrowed to the concrete request type
177     * initClient() always constructs (the client base class declares the wider
178     * AbstractRequest return type)
179     *
180     * @throws \Pop\Storage\Exception
181     * @return Request
182     */
183    protected function getClientRequest(): Request
184    {
185        $request = $this->client?->getRequest();
186
187        if (!($request instanceof Request)) {
188            throw new \Pop\Storage\Exception('Error: The client has not been initialized with a request.');
189        }
190
191        return $request;
192    }
193
194    /**
195     * Set auth
196     *
197     * @param  Auth $auth
198     * @return Azure
199     */
200    public function setAuth(Auth $auth): Azure
201    {
202        $this->auth = $auth;
203        return $this;
204    }
205
206    /**
207     * Get auth
208     *
209     * @return ?Auth
210     */
211    public function getAuth(): ?Auth
212    {
213        return $this->auth;
214    }
215
216    /**
217     * Has auth
218     *
219     * @return bool
220     */
221    public function hasAuth(): bool
222    {
223        return ($this->auth !== null);
224    }
225
226    /**
227     * Set handler
228     *
229     * @param  HandlerInterface $handler
230     * @return Azure
231     */
232    public function setHandler(HandlerInterface $handler): Azure
233    {
234        $this->handler = $handler;
235        return $this;
236    }
237
238    /**
239     * Get handler
240     *
241     * @return ?HandlerInterface
242     */
243    public function getHandler(): ?HandlerInterface
244    {
245        return $this->handler;
246    }
247
248    /**
249     * Has handler
250     *
251     * @return bool
252     */
253    public function hasHandler(): bool
254    {
255        return ($this->handler !== null);
256    }
257
258    /**
259     * Get the current directory as a blob-name prefix relative to the base directory
260     * (the container), i.e. what chdir() has descended into. Returns an empty string when
261     * the current directory is the base directory itself.
262     *
263     * @return string
264     */
265    private function currentPrefix(): string
266    {
267        if ($this->baseDirectory === $this->directory) {
268            return '';
269        }
270
271        $directory = substr($this->directory, strlen($this->baseDirectory));
272
273        return trim(str_replace('\\', '/', $directory), '/');
274    }
275
276    /**
277     * Resolve a filename/directory argument to its full Azure blob URI, accounting for
278     * the current directory (chdir()) relative to the base directory
279     *
280     * The path is scrubbed first, so a caller-supplied filename can neither traverse out of
281     * the storage directory via '..' nor escape the container entirely by looking like an
282     * absolute path. This is only for internal, current-directory-scoped paths - the
283     * *External() methods set their genuinely external URI on the request directly.
284     *
285     * @param  string $path
286     * @throws PathTraversalException
287     * @return string
288     */
289    private function resolveUri(string $path): string
290    {
291        $path   = $this->scrub($path);
292        $prefix = $this->currentPrefix();
293
294        // The blob URI is always /{container}/{current directory}/{path} - the container comes
295        // first, so anything chdir() descended into belongs between it and the path.
296        return '/' . $this->baseDirectory . (($prefix !== '') ? '/' . $prefix : '') . '/' . $path;
297    }
298
299    /**
300     * Make directory
301     *
302     * @param  string $directory
303     * @return void
304     */
305    public function mkdir(string $directory): void
306    {
307        /**
308         * Azure storage doesn't allow the creation of empty "directories" (prefixes.)
309         * A new "directory" (prefix) is automatically created with an uploaded file that utilizes a prefix
310         */
311    }
312
313    /**
314     * Remove a directory
315     *
316     * @param  string $directory
317     * @return void
318     */
319    public function rmdir(string $directory): void
320    {
321        /**
322         * Azure storage doesn't allow the direct removal of "directories" (prefixes.)
323         * A "directory" (prefix) is automatically removed when the last file that utilizes the prefix is deleted.
324         */
325    }
326
327    /**
328     * List directories
329     *
330     * @param  ?string $search
331     * @param  bool    $recursive
332     * @return array
333     */
334    public function listDirs(?string $search = null, bool $recursive = false): array
335    {
336        $dirs = $this->walkBlobs($recursive, 'directory');
337
338        if ($search !== null) {
339            $dirs = $this->searchFilter($dirs, $search);
340        }
341
342        return $dirs;
343    }
344
345    /**
346     * List files
347     *
348     * @param  ?string $search
349     * @param  bool    $recursive
350     * @return array
351     */
352    public function listFiles(?string $search = null, bool $recursive = false): array
353    {
354        $files = $this->walkBlobs($recursive, 'file');
355
356        if ($search !== null) {
357            $files = $this->searchFilter($files, $search);
358        }
359
360        return $files;
361    }
362
363    /**
364     * Walk every page of the blob listing for the current directory, returning blob
365     * names matching $resourceType ('file' or 'directory')
366     *
367     * @param  bool   $recursive
368     * @param  string $resourceType
369     * @return array
370     */
371    private function walkBlobs(bool $recursive, string $resourceType): array
372    {
373        $results = [];
374        $uri     = '/' . $this->baseDirectory;
375        $params  = ['restype' => 'container', 'comp' => 'list'];
376        $prefix  = $this->currentPrefix();
377
378        if (!$recursive) {
379            $params['delimiter'] = '/';
380        }
381        if ($prefix !== '') {
382            $prefix          .= '/';
383            $params['prefix'] = $prefix;
384        }
385
386        $pageCount = 0;
387        $this->initClient();
388
389        do {
390            if (++$pageCount > static::MAX_LIST_PAGES) {
391                throw new UnableToReadFileException(
392                    'Error: Exceeded maximum page count while listing blobs - the continuation marker may be malformed.'
393                );
394            }
395
396            // Reuse the same client/request across pages instead of rebuilding the whole
397            // object graph via initClient() every iteration - only the Date header (part of
398            // the signed string), query and signature need to be refreshed per page.
399            $this->getClientRequest()->addHeader('Date', gmdate('D, d M Y H:i:s T'));
400            $this->getClientRequest()->setQuery($params);
401            $this->getClientRequest()->setUri($uri);
402            $this->auth->signRequest($this->getClientRequest());
403            $response = $this->client->send();
404
405            if (is_array($response) && !empty($response['Blobs']) && !empty($response['Blobs']['Blob'])) {
406                $blobs = (!isset($response['Blobs']['Blob'][0])) ? [$response['Blobs']['Blob']] : $response['Blobs']['Blob'];
407                foreach ($blobs as $blob) {
408                    if (isset($blob['Properties']) && isset($blob['Properties']['ResourceType']) &&
409                        ($blob['Properties']['ResourceType'] == $resourceType)) {
410                        // Azure names blobs relative to the container root; results have to be
411                        // relative to the current directory so they round-trip straight back into
412                        // fetchFile()/deleteFile(), the same as the Local and S3 adapters.
413                        $name = ($prefix !== '' && str_starts_with($blob['Name'], $prefix)) ?
414                            substr($blob['Name'], strlen($prefix)) : $blob['Name'];
415
416                        if (($name !== '') && ($recursive || !str_contains(rtrim($name, '/'), '/'))) {
417                            $results[] = $name;
418                        }
419                    }
420                }
421            }
422
423            $nextMarker = (is_array($response) && !empty($response['NextMarker'])) ? $response['NextMarker'] : null;
424            if ($nextMarker !== null) {
425                $params['marker'] = $nextMarker;
426            }
427        } while ($nextMarker !== null);
428
429        return $results;
430    }
431
432    /**
433     * Put file
434     *
435     * @param  string $fileFrom
436     * @param  bool $copy
437     * @throws FileNotFoundException|UnableToWriteFileException
438     * @return void
439     */
440    public function putFile(string $fileFrom, bool $copy = true): void
441    {
442        if (!file_exists($fileFrom)) {
443            throw new FileNotFoundException('Error: The file \'' . $fileFrom . '\' was not found.');
444        }
445
446        $uri = $this->resolveUri(basename($fileFrom));
447
448        $fileContents = file_get_contents($fileFrom);
449
450        $this->initClient('PUT', [
451            'content-length'         => (string)strlen($fileContents),
452            'x-ms-blob-type'         => 'BlockBlob',
453            'x-ms-blob-content-type' => File::getFileMimeType($fileFrom) ?? self::DEFAULT_CONTENT_TYPE
454        ], false);
455        $this->getClientRequest()->setUri($uri);
456        $this->getClientRequest()->setBody($fileContents);
457        $this->auth->signRequest($this->getClientRequest());
458        $response = $this->client->send();
459
460        if (!$response->isSuccess()) {
461            throw new UnableToWriteFileException('Error: Unable to write file \'' . $fileFrom . '\' (HTTP ' . $response->getCode() . ').');
462        }
463    }
464
465    /**
466     * Put file contents
467     *
468     * @param  string $filename
469     * @param  string $fileContents
470     * @return void
471     */
472    public function putFileContents(string $filename, string $fileContents): void
473    {
474        $uri = $this->resolveUri($filename);
475
476        $this->initClient('PUT', [
477            'content-length'         => (string)strlen($fileContents),
478            'x-ms-blob-type'         => 'BlockBlob',
479            'x-ms-blob-content-type' => File::getFileMimeType($filename) ?? self::DEFAULT_CONTENT_TYPE
480        ], false);
481        $this->getClientRequest()->setUri($uri);
482        $this->getClientRequest()->setBody($fileContents);
483        $this->auth->signRequest($this->getClientRequest());
484        $response = $this->client->send();
485
486        if (!$response->isSuccess()) {
487            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\' (HTTP ' . $response->getCode() . ').');
488        }
489    }
490
491    /**
492     * Put file from a stream resource
493     *
494     * @param  string $filename
495     * @param  mixed  $resource
496     * @throws UnableToWriteFileException
497     * @return void
498     */
499    public function putFileStream(string $filename, mixed $resource): void
500    {
501        if (!is_resource($resource)) {
502            throw new UnableToWriteFileException('Error: The provided resource is not a valid stream.');
503        }
504
505        $uri  = $this->resolveUri($filename);
506        $stat = fstat($resource);
507
508        $body = new Body();
509        $body->setContentFromStream($resource);
510
511        $this->initClient('PUT', [
512            'content-length'         => (string)$stat['size'],
513            'x-ms-blob-type'         => 'BlockBlob',
514            'x-ms-blob-content-type' => File::getFileMimeType($filename) ?? self::DEFAULT_CONTENT_TYPE
515        ], false);
516        $this->getClientRequest()->setUri($uri);
517        $this->getClientRequest()->setBody($body);
518        $this->auth->signRequest($this->getClientRequest());
519        $response = $this->client->send();
520
521        if (!$response->isSuccess()) {
522            throw new UnableToWriteFileException('Error: Unable to write file \'' . $filename . '\' (HTTP ' . $response->getCode() . ').');
523        }
524    }
525
526    /**
527     * Upload file from server request $_FILES['file']
528     *
529     * @param  array $file
530     * @throws UnableToWriteFileException|PathTraversalException
531     * @return void
532     */
533    public function uploadFile(array $file): void
534    {
535        if (!isset($file['tmp_name']) || !isset($file['name'])) {
536            throw new UnableToWriteFileException('Error: The uploaded file array was not valid.');
537        }
538        if (!file_exists($file['tmp_name'])) {
539            throw new UnableToWriteFileException('Error: The uploaded file array was not valid.');
540        }
541
542        $uri = $this->resolveUri($file['name']);
543
544        $fileContents = file_get_contents($file['tmp_name']);
545
546        $this->initClient('PUT', [
547            'content-length'         => (string)strlen($fileContents),
548            'x-ms-blob-type'         => 'BlockBlob',
549            'x-ms-blob-content-type' => File::getFileMimeType($file['name']) ?? self::DEFAULT_CONTENT_TYPE
550        ], false);
551        $this->getClientRequest()->setUri($uri);
552        $this->getClientRequest()->setBody($fileContents);
553        $this->auth->signRequest($this->getClientRequest());
554        $response = $this->client->send();
555
556        if (!$response->isSuccess()) {
557            throw new UnableToWriteFileException('Error: Unable to write file \'' . $file['name'] . '\' (HTTP ' . $response->getCode() . ').');
558        }
559    }
560
561    /**
562     * Copy file
563     *
564     * @param  string $sourceFile
565     * @param  string $destFile
566     * @return void
567     */
568    public function copyFile(string $sourceFile, string $destFile): void
569    {
570        $sourceFileInfo = $this->fetchFileInfo($sourceFile);
571
572        if ($sourceFileInfo['code'] === 404) {
573            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
574        }
575
576        $sourceUri = $this->resolveUri($sourceFile);
577        $destUri   = $this->resolveUri($destFile);
578
579        $this->initClient('PUT', [
580            'content-length'   => $sourceFileInfo['headers']['Content-Length'],
581            'x-ms-copy-source' => $this->auth->getBaseUri() . $sourceUri,
582        ], false);
583        $this->getClientRequest()->setUri($destUri);
584        $this->auth->signRequest($this->getClientRequest());
585        $response = $this->client->send();
586
587        if (!$response->isSuccess()) {
588            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $sourceFile . '\' (HTTP ' . $response->getCode() . ').');
589        }
590    }
591
592    /**
593     * Copy file to a location external to the current location
594     *
595     * @param  string $sourceFile
596     * @param  string $externalFile
597     * @return void
598     */
599    public function copyFileToExternal(string $sourceFile, string $externalFile): void
600    {
601        $sourceFileInfo = $this->fetchFileInfo($sourceFile);
602
603        if ($sourceFileInfo['code'] === 404) {
604            throw new FileNotFoundException('Error: The file \'' . $sourceFile . '\' was not found.');
605        }
606
607        $sourceUri = $this->resolveUri($sourceFile);
608
609        $this->initClient('PUT', [
610            'content-length'   => $sourceFileInfo['headers']['Content-Length'],
611            'x-ms-copy-source' => $this->auth->getBaseUri() . $sourceUri,
612        ], false);
613        $this->getClientRequest()->setUri($externalFile);
614        $this->auth->signRequest($this->getClientRequest());
615        $response = $this->client->send();
616
617        if (!$response->isSuccess()) {
618            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $sourceFile . '\' (HTTP ' . $response->getCode() . ').');
619        }
620    }
621
622    /**
623     * Copy file from a location external to the current location
624     *
625     * @param  string $externalFile
626     * @param  string $destFile
627     * @return void
628     */
629    public function copyFileFromExternal(string $externalFile, string $destFile): void
630    {
631        $this->initClient('HEAD', [], false);
632        $this->getClientRequest()->setUri($externalFile);
633        $this->auth->signRequest($this->getClientRequest());
634        $response = $this->client->send();
635
636        if ($response->getCode() === 404) {
637            throw new FileNotFoundException('Error: The file \'' . $externalFile . '\' was not found.');
638        }
639        if (!$response->isSuccess()) {
640            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $externalFile . '\' (HTTP ' . $response->getCode() . ').');
641        }
642
643        $destUri = $this->resolveUri($destFile);
644
645        $this->initClient('PUT', [
646            'content-length'   => $response->getHeaderValueAsString('Content-Length'),
647            'x-ms-copy-source' => $this->auth->getBaseUri() . $externalFile,
648        ], false);
649        $this->getClientRequest()->setUri($destUri);
650        $this->auth->signRequest($this->getClientRequest());
651        $response = $this->client->send();
652
653        if (!$response->isSuccess()) {
654            throw new UnableToCopyFileException('Error: Unable to copy file \'' . $externalFile . '\' (HTTP ' . $response->getCode() . ').');
655        }
656    }
657
658    /**
659     * Move file to a location external to the current location
660     *
661     * @param  string $sourceFile
662     * @param  string $externalFile
663     * @return void
664     */
665    public function moveFileToExternal(string $sourceFile, string $externalFile): void
666    {
667        $this->copyFileToExternal($sourceFile, $externalFile);
668        try {
669            $this->deleteFile($sourceFile);
670        } catch (UnableToDeleteFileException $exception) {
671            throw new UnableToMoveFileException(
672                'Error: Copied \'' . $sourceFile . '\' to \'' . $externalFile . '\' but failed to remove the source.', 0, $exception
673            );
674        }
675    }
676
677    /**
678     * Move file from a location external to the current location
679     *
680     * @param  string  $externalFile
681     * @param  string  $destFile
682     * @param  ?string $snapshots ['include', 'only', null]
683     * @return void
684     */
685    public function moveFileFromExternal(string $externalFile, string $destFile, ?string $snapshots = 'include'): void
686    {
687        $this->copyFileFromExternal($externalFile, $destFile);
688
689        $headers = [];
690        if ($snapshots !== null) {
691            $headers['x-ms-delete-snapshots'] = ($snapshots == 'only') ? 'only' : 'include';
692        }
693
694        $this->initClient('DELETE', $headers, false);
695        $this->getClientRequest()->setUri($externalFile);
696        $this->auth->signRequest($this->getClientRequest());
697        $response = $this->client->send();
698
699        if ($response->getCode() === 404) {
700            throw new FileNotFoundException('Error: The file \'' . $externalFile . '\' was not found.');
701        }
702        if (!$response->isSuccess()) {
703            throw new UnableToMoveFileException(
704                'Error: Copied \'' . $externalFile . '\' to \'' . $destFile . '\' but failed to remove the source (HTTP ' . $response->getCode() . ').'
705            );
706        }
707    }
708
709    /**
710     * Rename file
711     *
712     * @param  string $oldFile
713     * @param  string $newFile
714     * @return void
715     */
716    public function renameFile(string $oldFile, string $newFile): void
717    {
718        $this->copyFile($oldFile, $newFile);
719        try {
720            $this->deleteFile($oldFile);
721        } catch (UnableToDeleteFileException $exception) {
722            throw new UnableToMoveFileException(
723                'Error: Copied \'' . $oldFile . '\' to \'' . $newFile . '\' but failed to remove the source.', 0, $exception
724            );
725        }
726    }
727
728    /**
729     * Replace file
730     *
731     * @param  string $filename
732     * @param  string $fileContents
733     * @throws FileNotFoundException
734     * @return void
735     */
736    public function replaceFileContents(string $filename, string $fileContents): void
737    {
738        if (!$this->fileExists($filename)) {
739            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
740        }
741        $this->putFileContents($filename, $fileContents);
742    }
743
744    /**
745     * Delete file
746     *
747     * @param  string  $filename
748     * @param  ?string $snapshots ['include', 'only', null]
749     * @return void
750     */
751    public function deleteFile(string $filename, ?string $snapshots = 'include'): void
752    {
753        $uri = $this->resolveUri($filename);
754
755        $headers = [];
756        if ($snapshots !== null) {
757            $headers['x-ms-delete-snapshots'] = ($snapshots == 'only') ? 'only' : 'include';
758        }
759
760        $this->initClient('DELETE', $headers, false);
761        $this->getClientRequest()->setUri($uri);
762        $this->auth->signRequest($this->getClientRequest());
763        $response = $this->client->send();
764
765        if ($response->getCode() === 404) {
766            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
767        }
768        if (!$response->isSuccess()) {
769            throw new UnableToDeleteFileException('Error: Unable to delete file \'' . $filename . '\' (HTTP ' . $response->getCode() . ').');
770        }
771    }
772
773    /**
774     * Fetch file
775     *
776     * @param  string $filename
777     * @param  bool   $raw
778     * @return mixed
779     */
780    public function fetchFile(string $filename, bool $raw = true): mixed
781    {
782        $uri = $this->resolveUri($filename);
783
784        $this->initClient('GET', [], false);
785        $this->getClientRequest()->setUri($uri);
786        $this->auth->signRequest($this->getClientRequest());
787        $response = $this->client->send();
788
789        if ($response->getCode() === 404) {
790            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
791        }
792        if (!$response->isSuccess()) {
793            throw new UnableToReadFileException('Error: Unable to read file \'' . $filename . '\' (HTTP ' . $response->getCode() . ').');
794        }
795
796        return ($raw) ? $response->getBody()->getContent() : $response;
797    }
798
799    /**
800     * Fetch file as a stream resource
801     *
802     * @param  string $filename
803     * @throws FileNotFoundException|UnableToReadFileException
804     * @return mixed
805     */
806    public function fetchFileStream(string $filename): mixed
807    {
808        $uri = $this->resolveUri($filename);
809
810        $this->initClient('GET', [], false);
811        $this->getClientRequest()->setUri($uri);
812        $this->auth->signRequest($this->getClientRequest());
813        $response = $this->client->send();
814
815        if ($response->getCode() === 404) {
816            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
817        }
818        if (!$response->isSuccess()) {
819            throw new UnableToReadFileException('Error: Unable to read file \'' . $filename . '\' (HTTP ' . $response->getCode() . ').');
820        }
821
822        $stream = $response->getBody()->getStream();
823        if (is_resource($stream)) {
824            rewind($stream);
825            return $stream;
826        }
827
828        // Body wasn't backed by a real stream (e.g. Mock handler content) - fall back to
829        // a fresh in-memory stream over the buffered content so the return contract
830        // (always a resource) holds regardless of what produced the response body.
831        $memory = fopen('php://memory', 'r+');
832        fwrite($memory, $response->getBody()->getContent());
833        rewind($memory);
834        return $memory;
835    }
836
837    /**
838     * Fetch file info
839     *
840     * @param  string $filename
841     * @return array
842     */
843    public function fetchFileInfo(string $filename): array
844    {
845        $uri = $this->resolveUri($filename);
846
847        $this->initClient('HEAD', [], false);
848        $this->getClientRequest()->setUri($uri);
849        $this->auth->signRequest($this->getClientRequest());
850        $response = $this->client->send();
851
852        return [
853            'code'    => $response->getCode(),
854            'message' => $response->getMessage(),
855            'headers' => $response->getHeadersAsArray(),
856            'isError' => $response->isError()
857        ];
858    }
859
860    /**
861     * Get a temporary (presigned) URL for the file, valid for $expiresInSeconds
862     *
863     * @param  string $filename
864     * @param  int    $expiresInSeconds
865     * @return string
866     */
867    public function getTemporaryUrl(string $filename, int $expiresInSeconds = 900): string
868    {
869        $uri   = $this->resolveUri($filename);
870        $token = $this->auth->generateSasToken($uri, $expiresInSeconds, 'r');
871
872        return $this->auth->getBaseUri() . $uri . '?' . $token;
873    }
874
875    /**
876     * File exists
877     *
878     * @param  string $filename
879     * @return bool
880     */
881    public function fileExists(string $filename): bool
882    {
883        $info = $this->fetchFileInfo($filename);
884        return (isset($info['code']) && ((int)$info['code'] == 200));
885    }
886
887    /**
888     * Check if is a dir
889     *
890     * @param  string $directory
891     * @return bool
892     */
893    public function isDir(string $directory): bool
894    {
895        if (str_starts_with($directory, '/')) {
896            $directory = substr($directory, 1);
897        }
898        if (str_ends_with($directory, '/')) {
899            $directory = substr($directory, 0, -1);
900        }
901        $info = $this->fetchFileInfo($directory);
902        return (isset($info['headers']) && isset($info['headers']['x-ms-resource-type']) &&
903            $info['headers']['x-ms-resource-type'] == 'directory');
904    }
905
906    /**
907     * Check if is a file
908     *
909     * @param  string $filename
910     * @return bool
911     */
912    public function isFile(string $filename): bool
913    {
914        $info = $this->fetchFileInfo($filename);
915        return (isset($info['headers']) && isset($info['headers']['x-ms-resource-type']) &&
916            $info['headers']['x-ms-resource-type'] == 'file');
917    }
918
919    /**
920     * Get file size
921     *
922     * @param  string $filename
923     * @throws FileNotFoundException|UnableToReadFileException
924     * @return int
925     */
926    public function getFileSize(string $filename): int
927    {
928        $info = $this->fetchFileInfo($filename);
929        if ($info['code'] == 404) {
930            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
931        }
932        if (!isset($info['headers']['Content-Length'])) {
933            throw new UnableToReadFileException(
934                'Error: No content length returned for \'' . $filename . '\'.'
935            );
936        }
937        return (int)$info['headers']['Content-Length'];
938    }
939
940    /**
941     * Get file type
942     *
943     * @param  string $filename
944     * @throws FileNotFoundException|UnableToReadFileException
945     * @return string
946     */
947    public function getFileType(string $filename): string
948    {
949        $info = $this->fetchFileInfo($filename);
950        if ($info['code'] == 404) {
951            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
952        }
953        if (isset($info['headers']['x-ms-resource-type']) && ($info['headers']['x-ms-resource-type'] == 'file')) {
954            return 'file';
955        } else if (isset($info['headers']['x-ms-resource-type']) && ($info['headers']['x-ms-resource-type'] == 'directory')) {
956            return 'dir';
957        } else {
958            throw new UnableToReadFileException(
959                'Error: No resource type returned for \'' . $filename . '\'.'
960            );
961        }
962    }
963
964    /**
965     * Get file modified time
966     *
967     * @param  string $filename
968     * @throws FileNotFoundException|UnableToReadFileException
969     * @return int|string
970     */
971    public function getFileMTime(string $filename): int|string
972    {
973        $info = $this->fetchFileInfo($filename);
974        if ($info['code'] == 404) {
975            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
976        }
977        if (isset($info['headers']) && !empty($info['headers']['Last-Modified'])) {
978            return $info['headers']['Last-Modified'];
979        } else if (isset($info['headers']) && !empty($info['headers']['x-ms-creation-time'])) {
980            return $info['headers']['x-ms-creation-time'];
981        } else {
982            throw new UnableToReadFileException(
983                'Error: No modified time returned for \'' . $filename . '\'.'
984            );
985        }
986    }
987
988    /**
989     * Create MD5 checksum of the file
990     *
991     * @param  string $filename
992     * @throws FileNotFoundException|UnableToReadFileException
993     * @return string
994     */
995    public function md5File(string $filename): string
996    {
997        $info = $this->fetchFileInfo($filename);
998        if ($info['code'] == 404) {
999            throw new FileNotFoundException('Error: The file \'' . $filename . '\' was not found.');
1000        }
1001        if (isset($info['headers']) && !empty($info['headers']['Content-MD5'])) {
1002            return $info['headers']['Content-MD5'];
1003        } else {
1004            throw new UnableToReadFileException(
1005                'Error: No MD5 checksum returned for \'' . $filename . '\'.'
1006            );
1007        }
1008    }
1009
1010}