407 lines
14 KiB
PHP
407 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace League\Flysystem\AzureBlobStorage;
|
|
|
|
use DateTime;
|
|
use DateTimeInterface;
|
|
use League\Flysystem\ChecksumAlgoIsNotSupported;
|
|
use League\Flysystem\ChecksumProvider;
|
|
use League\Flysystem\Config;
|
|
use League\Flysystem\DirectoryAttributes;
|
|
use League\Flysystem\FileAttributes;
|
|
use League\Flysystem\FilesystemAdapter;
|
|
use League\Flysystem\PathPrefixer;
|
|
use League\Flysystem\UnableToCheckDirectoryExistence;
|
|
use League\Flysystem\UnableToCheckFileExistence;
|
|
use League\Flysystem\UnableToCopyFile;
|
|
use League\Flysystem\UnableToDeleteDirectory;
|
|
use League\Flysystem\UnableToDeleteFile;
|
|
use League\Flysystem\UnableToGenerateTemporaryUrl;
|
|
use League\Flysystem\UnableToMoveFile;
|
|
use League\Flysystem\UnableToProvideChecksum;
|
|
use League\Flysystem\UnableToReadFile;
|
|
use League\Flysystem\UnableToRetrieveMetadata;
|
|
use League\Flysystem\UnableToSetVisibility;
|
|
use League\Flysystem\UnableToWriteFile;
|
|
use League\Flysystem\UrlGeneration\PublicUrlGenerator;
|
|
use League\Flysystem\UrlGeneration\TemporaryUrlGenerator;
|
|
use League\MimeTypeDetection\FinfoMimeTypeDetector;
|
|
use League\MimeTypeDetection\MimeTypeDetector;
|
|
use MicrosoftAzure\Storage\Blob\BlobRestProxy;
|
|
use MicrosoftAzure\Storage\Blob\BlobSharedAccessSignatureHelper;
|
|
use MicrosoftAzure\Storage\Blob\Models\BlobProperties;
|
|
use MicrosoftAzure\Storage\Blob\Models\CreateBlockBlobOptions;
|
|
use MicrosoftAzure\Storage\Blob\Models\ListBlobsOptions;
|
|
use MicrosoftAzure\Storage\Common\Exceptions\ServiceException;
|
|
use MicrosoftAzure\Storage\Common\Internal\Resources;
|
|
use MicrosoftAzure\Storage\Common\Internal\StorageServiceSettings;
|
|
use MicrosoftAzure\Storage\Common\Models\ContinuationToken;
|
|
use Throwable;
|
|
use function base64_decode;
|
|
use function bin2hex;
|
|
use function stream_get_contents;
|
|
|
|
class AzureBlobStorageAdapter implements FilesystemAdapter, PublicUrlGenerator, ChecksumProvider, TemporaryUrlGenerator
|
|
{
|
|
/** @var string[] */
|
|
private const META_OPTIONS = [
|
|
'CacheControl',
|
|
'ContentType',
|
|
'Metadata',
|
|
'ContentLanguage',
|
|
'ContentEncoding',
|
|
];
|
|
const ON_VISIBILITY_THROW_ERROR = 'throw';
|
|
const ON_VISIBILITY_IGNORE = 'ignore';
|
|
|
|
private MimeTypeDetector $mimeTypeDetector;
|
|
private PathPrefixer $prefixer;
|
|
|
|
public function __construct(
|
|
private BlobRestProxy $client,
|
|
private string $container,
|
|
string $prefix = '',
|
|
?MimeTypeDetector $mimeTypeDetector = null,
|
|
private int $maxResultsForContentsListing = 5000,
|
|
private string $visibilityHandling = self::ON_VISIBILITY_THROW_ERROR,
|
|
private ?StorageServiceSettings $serviceSettings = null,
|
|
) {
|
|
$this->prefixer = new PathPrefixer($prefix);
|
|
$this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector();
|
|
}
|
|
|
|
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->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function read(string $path): string
|
|
{
|
|
$response = $this->readStream($path);
|
|
|
|
return stream_get_contents($response);
|
|
}
|
|
|
|
public function readStream(string $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->getMessage(), $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->getMessage(), $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
|
|
{
|
|
throw UnableToRetrieveMetadata::visibility($path, 'Azure does not support visibility');
|
|
}
|
|
|
|
public function mimeType(string $path): FileAttributes
|
|
{
|
|
try {
|
|
return $this->fetchMetadata($this->prefixer->prefixPath($path));
|
|
} catch (Throwable $exception) {
|
|
throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function lastModified(string $path): FileAttributes
|
|
{
|
|
try {
|
|
return $this->fetchMetadata($this->prefixer->prefixPath($path));
|
|
} catch (Throwable $exception) {
|
|
throw UnableToRetrieveMetadata::lastModified($path, $exception->getMessage(), $exception);
|
|
}
|
|
}
|
|
|
|
public function fileSize(string $path): FileAttributes
|
|
{
|
|
try {
|
|
return $this->fetchMetadata($this->prefixer->prefixPath($path));
|
|
} catch (Throwable $exception) {
|
|
throw UnableToRetrieveMetadata::fileSize($path, $exception->getMessage(), $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);
|
|
}
|
|
|
|
/**
|
|
* @param string|resource $contents
|
|
*/
|
|
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->getMessage(), $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(),
|
|
['md5_checksum' => $properties->getContentMD5()]
|
|
);
|
|
}
|
|
|
|
public function publicUrl(string $path, Config $config): string
|
|
{
|
|
$location = $this->prefixer->prefixPath($path);
|
|
|
|
return $this->client->getBlobUrl($this->container, $location);
|
|
}
|
|
|
|
public function checksum(string $path, Config $config): string
|
|
{
|
|
$algo = $config->get('checksum_algo', 'md5');
|
|
|
|
if ($algo !== 'md5') {
|
|
throw new ChecksumAlgoIsNotSupported();
|
|
}
|
|
|
|
try {
|
|
$metadata = $this->fetchMetadata($this->prefixer->prefixPath($path));
|
|
$checksum = $metadata->extraMetadata()['md5_checksum'] ?? '__not_specified';
|
|
} catch (Throwable $exception) {
|
|
throw new UnableToProvideChecksum($exception->getMessage(), $path, $exception);
|
|
}
|
|
|
|
if ($checksum === '__not_specified') {
|
|
throw new UnableToProvideChecksum('No checksum provided in metadata', $path);
|
|
}
|
|
|
|
return bin2hex(base64_decode($checksum));
|
|
}
|
|
|
|
public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string
|
|
{
|
|
if ( ! $this->serviceSettings instanceof StorageServiceSettings) {
|
|
throw UnableToGenerateTemporaryUrl::noGeneratorConfigured(
|
|
$path,
|
|
'The $serviceSettings constructor parameter must be set to generate temporary URLs.',
|
|
);
|
|
}
|
|
|
|
try {
|
|
$sas = new BlobSharedAccessSignatureHelper($this->serviceSettings->getName(), $this->serviceSettings->getKey());
|
|
$baseUrl = $this->publicUrl($path, $config);
|
|
$resourceName = $this->container . '/' . ltrim($this->prefixer->prefixPath($path), '/');
|
|
$token = $sas->generateBlobServiceSharedAccessSignatureToken(
|
|
Resources::RESOURCE_TYPE_BLOB,
|
|
$resourceName,
|
|
'r', // read
|
|
DateTime::createFromInterface($expiresAt),
|
|
$config->get('signed_start', ''),
|
|
$config->get('signed_ip', ''),
|
|
$config->get('signed_protocol', 'https'),
|
|
$config->get('signed_identifier', ''),
|
|
$config->get('cache_control', ''),
|
|
$config->get('content_disposition', $config->get('content_deposition', '')),
|
|
$config->get('content_encoding', ''),
|
|
$config->get('content_language', ''),
|
|
$config->get('content_type', ''),
|
|
);
|
|
|
|
return "$baseUrl?$token";
|
|
} catch (Throwable $exception) {
|
|
throw UnableToGenerateTemporaryUrl::dueToError($path, $exception);
|
|
}
|
|
}
|
|
}
|