diff --git a/composer.json b/composer.json index f60385e1c..14aa162ec 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "ext-zip": "*", "ext-fileinfo": "*", "ext-ftp": "*", + "microsoft/azure-storage-blob": "^1.1", "phpunit/phpunit": "^9.5.11", "phpstan/phpstan": "^0.12.26", "phpseclib/phpseclib": "^2.0", diff --git a/src/AzureBlobStorage/.gitattributes b/src/AzureBlobStorage/.gitattributes new file mode 100644 index 000000000..6250eb06e --- /dev/null +++ b/src/AzureBlobStorage/.gitattributes @@ -0,0 +1,7 @@ +* text=auto + +.github export-ignore +.gitattributes export-ignore +.gitignore export-ignore +**/*Test.php export-ignore +**/*Stub.php export-ignore diff --git a/src/AzureBlobStorage/.github/FUNDING.yml b/src/AzureBlobStorage/.github/FUNDING.yml new file mode 100644 index 000000000..39b084a29 --- /dev/null +++ b/src/AzureBlobStorage/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: [frankdejonge] +tidelift: "packagist/league/flysystem" +custom: "https://offset.earth/frankdejonge" diff --git a/src/AzureBlobStorage/AzureBlobStorageAdapter.php b/src/AzureBlobStorage/AzureBlobStorageAdapter.php new file mode 100644 index 000000000..89283add0 --- /dev/null +++ b/src/AzureBlobStorage/AzureBlobStorageAdapter.php @@ -0,0 +1,342 @@ +client = $client; + $this->container = $container; + $this->prefixer = new PathPrefixer($prefix); + $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); + $this->maxResultsForContentsListing = $maxResultsForContentsListing; + $this->visibilityHandling = $visibilityHandling; + } + + public function copy(string $source, string $destination, Config $config): void + { + $resolvedDestination = $this->prefixer->prefixPath($destination); + $resolvedSource = $this->prefixer->prefixPath($source); + + try { + $this->client->copyBlob( + $this->container, + $resolvedDestination, + $this->container, + $resolvedSource + ); + } catch (Throwable $throwable) { + throw UnableToCopyFile::fromLocationTo($source, $destination, $throwable); + } + } + + public function delete(string $path): void + { + $location = $this->prefixer->prefixPath($path); + + try { + $this->client->deleteBlob($this->container, $location); + } catch (Throwable $exception) { + if ($exception instanceof ServiceException && $exception->getCode() === 404) { + return; + } + + throw UnableToDeleteFile::atLocation($path, '', $exception); + } + } + + public function read(string $path): string + { + $response = $this->readStream($path); + + return stream_get_contents($response); + } + + public function readStream($path) + { + $location = $this->prefixer->prefixPath($path); + + try { + $response = $this->client->getBlob($this->container, $location); + + return $response->getContentStream(); + } catch (Throwable $exception) { + throw UnableToReadFile::fromLocation($path, '', $exception); + } + } + + public function listContents(string $path, bool $deep = false): iterable + { + $resolved = $this->prefixer->prefixDirectoryPath($path); + + $options = new ListBlobsOptions(); + $options->setPrefix($resolved); + $options->setMaxResults($this->maxResultsForContentsListing); + + if ($deep === false) { + $options->setDelimiter('/'); + } + + do { + $response = $this->client->listBlobs($this->container, $options); + + foreach ($response->getBlobPrefixes() as $blobPrefix) { + yield new DirectoryAttributes($this->prefixer->stripDirectoryPrefix($blobPrefix->getName())); + } + + foreach ($response->getBlobs() as $blob) { + yield $this->normalizeBlobProperties( + $this->prefixer->stripPrefix($blob->getName()), + $blob->getProperties() + ); + } + + $continuationToken = $response->getContinuationToken(); + $options->setContinuationToken($continuationToken); + } while ($continuationToken instanceof ContinuationToken); + } + + public function fileExists(string $path): bool + { + $resolved = $this->prefixer->prefixPath($path); + try { + return $this->fetchMetadata($resolved) !== null; + } catch (Throwable $exception) { + if ($exception instanceof ServiceException && $exception->getCode() === 404) { + return false; + } + throw UnableToCheckFileExistence::forLocation($path, $exception); + } + } + + public function directoryExists(string $path): bool + { + $resolved = $this->prefixer->prefixDirectoryPath($path); + $options = new ListBlobsOptions(); + $options->setPrefix($resolved); + $options->setMaxResults(1); + + try { + $listResults = $this->client->listBlobs($this->container, $options); + + return count($listResults->getBlobs()) > 0; + } catch (Throwable $exception) { + throw UnableToCheckDirectoryExistence::forLocation($path, $exception); + } + } + + public function deleteDirectory(string $path): void + { + $resolved = $this->prefixer->prefixDirectoryPath($path); + $options = new ListBlobsOptions(); + $options->setPrefix($resolved); + + try { + start: + $listResults = $this->client->listBlobs($this->container, $options); + + foreach ($listResults->getBlobs() as $blob) { + $this->client->deleteBlob($this->container, $blob->getName()); + } + + $continuationToken = $listResults->getContinuationToken(); + + if ($continuationToken instanceof ContinuationToken) { + $options->setContinuationToken($continuationToken); + goto start; + } + } catch (Throwable $exception) { + throw UnableToDeleteDirectory::atLocation($path, '', $exception); + } + } + + public function createDirectory(string $path, Config $config): void + { + // this is not supported by Azure + } + + public function setVisibility(string $path, string $visibility): void + { + if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) { + throw UnableToSetVisibility::atLocation($path, 'Azure does not support this operation.'); + } + } + + public function visibility(string $path): FileAttributes + { + try { + return $this->fetchMetadata($this->prefixer->prefixPath($path)); + } catch (Throwable $exception) { + throw UnableToRetrieveMetadata::visibility($path, '', $exception); + } + } + + public function mimeType(string $path): FileAttributes + { + try { + return $this->fetchMetadata($this->prefixer->prefixPath($path)); + } catch (Throwable $exception) { + throw UnableToRetrieveMetadata::mimeType($path, '', $exception); + } + } + + public function lastModified(string $path): FileAttributes + { + try { + return $this->fetchMetadata($this->prefixer->prefixPath($path)); + } catch (Throwable $exception) { + throw UnableToRetrieveMetadata::lastModified($path, '', $exception); + } + } + + public function fileSize(string $path): FileAttributes + { + try { + return $this->fetchMetadata($this->prefixer->prefixPath($path)); + } catch (Throwable $exception) { + throw UnableToRetrieveMetadata::fileSize($path, '', $exception); + } + } + + public function move(string $source, string $destination, Config $config): void + { + try { + $this->copy($source, $destination, $config); + $this->delete($source); + } catch (Throwable $exception) { + throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); + } + } + + public function write(string $path, string $contents, Config $config): void + { + $this->upload($path, $contents, $config); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->upload($path, $contents, $config); + } + + private function upload(string $destination, $contents, Config $config): void + { + $resolved = $this->prefixer->prefixPath($destination); + try { + $options = $this->getOptionsFromConfig($config); + + if (empty($options->getContentType())) { + $options->setContentType($this->mimeTypeDetector->detectMimeType($resolved, $contents)); + } + + $this->client->createBlockBlob( + $this->container, + $resolved, + $contents, + $options + ); + } catch (Throwable $exception) { + throw UnableToWriteFile::atLocation($destination, '', $exception); + } + } + + private function fetchMetadata(string $path): FileAttributes + { + return $this->normalizeBlobProperties( + $path, + $this->client->getBlobProperties($this->container, $path)->getProperties() + ); + } + + private function getOptionsFromConfig(Config $config): CreateBlockBlobOptions + { + $options = new CreateBlockBlobOptions(); + + foreach (self::META_OPTIONS as $option) { + $setting = $config->get($option, '___NOT__SET___'); + + if ($setting === '___NOT__SET___') { + continue; + } + + call_user_func([$options, "set$option"], $setting); + } + + $mimeType = $config->get('mimetype'); + + if ($mimeType !== null) { + $options->setContentType($mimeType); + } + + return $options; + } + + private function normalizeBlobProperties(string $path, BlobProperties $properties): FileAttributes + { + return new FileAttributes( + $path, + $properties->getContentLength(), + null, + $properties->getLastModified()->getTimestamp(), + $properties->getContentType() + ); + } +} diff --git a/src/AzureBlobStorage/AzureBlobStorageTest.php b/src/AzureBlobStorage/AzureBlobStorageTest.php new file mode 100644 index 000000000..1d144c8c6 --- /dev/null +++ b/src/AzureBlobStorage/AzureBlobStorageTest.php @@ -0,0 +1,204 @@ +runScenario( + function () { + $this->givenWeHaveAnExistingFile('path.txt', 'contents'); + $adapter = $this->adapter(); + + $adapter->write('path.txt', 'new contents', new Config()); + + $contents = $adapter->read('path.txt'); + $this->assertEquals('new contents', $contents); + } + ); + } + + /** + * @test + */ + public function setting_visibility(): void + { + self::markTestSkipped('Azure does not support visibility'); + } + + /** + * @test + */ + public function failing_to_set_visibility(): void + { + self::markTestSkipped('Azure does not support visibility'); + } + + /** + * @test + */ + public function failing_to_check_visibility(): void + { + self::markTestSkipped('Azure does not support visibility'); + } + + public function fetching_unknown_mime_type_of_a_file(): void + { + $this->markTestSkipped('This adapter always returns a mime-type'); + } + + public function listing_contents_recursive(): void + { + $this->markTestSkipped('This adapter does not support creating directories'); + } + + /** + * @test + */ + public function copying_a_file(): void + { + $this->runScenario(function () { + $adapter = $this->adapter(); + $adapter->write( + 'source.txt', + 'contents to be copied', + new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) + ); + + $adapter->copy('source.txt', 'destination.txt', new Config()); + + $this->assertTrue($adapter->fileExists('source.txt')); + $this->assertTrue($adapter->fileExists('destination.txt')); + $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); + }); + } + + /** + * @test + */ + public function moving_a_file(): void + { + $this->runScenario(function () { + $adapter = $this->adapter(); + $adapter->write( + 'source.txt', + 'contents to be copied', + new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) + ); + $adapter->move('source.txt', 'destination.txt', new Config()); + $this->assertFalse( + $adapter->fileExists('source.txt'), + 'After moving a file should no longer exist in the original location.' + ); + $this->assertTrue( + $adapter->fileExists('destination.txt'), + 'After moving, a file should be present at the new location.' + ); + $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); + }); + } + + /** + * @test + */ + public function copying_a_file_again(): void + { + $this->runScenario(function () { + $adapter = $this->adapter(); + $adapter->write( + 'source.txt', + 'contents to be copied', + new Config() + ); + + $adapter->copy('source.txt', 'destination.txt', new Config()); + + $this->assertTrue($adapter->fileExists('source.txt')); + $this->assertTrue($adapter->fileExists('destination.txt')); + $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); + }); + } + + /** + * @test + */ + public function setting_visibility_can_be_ignored_not_supported(): void + { + $this->givenWeHaveAnExistingFile('some-file.md'); + $this->expectNotToPerformAssertions(); + + $client = BlobRestProxy::createBlobService(getenv('FLYSYSTEM_AZURE_DSN')); + $adapter = new AzureBlobStorageAdapter($client, self::CONTAINER_NAME, 'ci', null, 50000, AzureBlobStorageAdapter::ON_VISIBILITY_IGNORE); + + $adapter->setVisibility('some-file.md', 'public'); + } + + /** + * @test + */ + public function setting_visibility_causes_errors(): void + { + $this->givenWeHaveAnExistingFile('some-file.md'); + $adapter = $this->adapter(); + + $this->expectException(UnableToSetVisibility::class); + + $adapter->setVisibility('some-file.md', 'public'); + } + + /** + * @test + */ + public function checking_if_a_directory_exists_after_creating_it(): void + { + $this->markTestSkipped('This adapter does not support creating directories'); + } + + /** + * @test + */ + public function setting_visibility_on_a_file_that_does_not_exist(): void + { + $this->markTestSkipped('This adapter does not support visibility'); + } + + /** + * @test + */ + public function creating_a_directory(): void + { + $this->markTestSkipped('This adapter does not support creating directories'); + } +} diff --git a/src/AzureBlobStorage/composer.json b/src/AzureBlobStorage/composer.json new file mode 100644 index 000000000..ab26d8a3a --- /dev/null +++ b/src/AzureBlobStorage/composer.json @@ -0,0 +1,20 @@ +{ + "name": "league/flysystem-azure-blob-storage", + "autoload": { + "psr-4": { + "League\\Flysystem\\AzureBlobStorage\\": "" + } + }, + "require": { + "php": "^8.0.2", + "league/flysystem": "^2.0.0 || ^3.0.0", + "microsoft/azure-storage-blob": "^1.1" + }, + "license": "MIT", + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ] +}