├── AzureBlobStorageAdapter.php ├── LICENSE └── composer.json /AzureBlobStorageAdapter.php: -------------------------------------------------------------------------------- 1 | prefixer = new PathPrefixer($prefix); 72 | $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); 73 | } 74 | 75 | public function copy(string $source, string $destination, Config $config): void 76 | { 77 | $resolvedDestination = $this->prefixer->prefixPath($destination); 78 | $resolvedSource = $this->prefixer->prefixPath($source); 79 | 80 | try { 81 | $this->client->copyBlob( 82 | $this->container, 83 | $resolvedDestination, 84 | $this->container, 85 | $resolvedSource 86 | ); 87 | } catch (Throwable $throwable) { 88 | throw UnableToCopyFile::fromLocationTo($source, $destination, $throwable); 89 | } 90 | } 91 | 92 | public function delete(string $path): void 93 | { 94 | $location = $this->prefixer->prefixPath($path); 95 | 96 | try { 97 | $this->client->deleteBlob($this->container, $location); 98 | } catch (Throwable $exception) { 99 | if ($exception instanceof ServiceException && $exception->getCode() === 404) { 100 | return; 101 | } 102 | 103 | throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); 104 | } 105 | } 106 | 107 | public function read(string $path): string 108 | { 109 | $response = $this->readStream($path); 110 | 111 | return stream_get_contents($response); 112 | } 113 | 114 | public function readStream(string $path) 115 | { 116 | $location = $this->prefixer->prefixPath($path); 117 | 118 | try { 119 | $response = $this->client->getBlob($this->container, $location); 120 | 121 | return $response->getContentStream(); 122 | } catch (Throwable $exception) { 123 | throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); 124 | } 125 | } 126 | 127 | public function listContents(string $path, bool $deep = false): iterable 128 | { 129 | $resolved = $this->prefixer->prefixDirectoryPath($path); 130 | 131 | $options = new ListBlobsOptions(); 132 | $options->setPrefix($resolved); 133 | $options->setMaxResults($this->maxResultsForContentsListing); 134 | 135 | if ($deep === false) { 136 | $options->setDelimiter('/'); 137 | } 138 | 139 | do { 140 | $response = $this->client->listBlobs($this->container, $options); 141 | 142 | foreach ($response->getBlobPrefixes() as $blobPrefix) { 143 | yield new DirectoryAttributes($this->prefixer->stripDirectoryPrefix($blobPrefix->getName())); 144 | } 145 | 146 | foreach ($response->getBlobs() as $blob) { 147 | yield $this->normalizeBlobProperties( 148 | $this->prefixer->stripPrefix($blob->getName()), 149 | $blob->getProperties() 150 | ); 151 | } 152 | 153 | $continuationToken = $response->getContinuationToken(); 154 | $options->setContinuationToken($continuationToken); 155 | } while ($continuationToken instanceof ContinuationToken); 156 | } 157 | 158 | public function fileExists(string $path): bool 159 | { 160 | $resolved = $this->prefixer->prefixPath($path); 161 | try { 162 | return $this->fetchMetadata($resolved) !== null; 163 | } catch (Throwable $exception) { 164 | if ($exception instanceof ServiceException && $exception->getCode() === 404) { 165 | return false; 166 | } 167 | throw UnableToCheckFileExistence::forLocation($path, $exception); 168 | } 169 | } 170 | 171 | public function directoryExists(string $path): bool 172 | { 173 | $resolved = $this->prefixer->prefixDirectoryPath($path); 174 | $options = new ListBlobsOptions(); 175 | $options->setPrefix($resolved); 176 | $options->setMaxResults(1); 177 | 178 | try { 179 | $listResults = $this->client->listBlobs($this->container, $options); 180 | 181 | return count($listResults->getBlobs()) > 0; 182 | } catch (Throwable $exception) { 183 | throw UnableToCheckDirectoryExistence::forLocation($path, $exception); 184 | } 185 | } 186 | 187 | public function deleteDirectory(string $path): void 188 | { 189 | $resolved = $this->prefixer->prefixDirectoryPath($path); 190 | $options = new ListBlobsOptions(); 191 | $options->setPrefix($resolved); 192 | 193 | try { 194 | start: 195 | $listResults = $this->client->listBlobs($this->container, $options); 196 | 197 | foreach ($listResults->getBlobs() as $blob) { 198 | $this->client->deleteBlob($this->container, $blob->getName()); 199 | } 200 | 201 | $continuationToken = $listResults->getContinuationToken(); 202 | 203 | if ($continuationToken instanceof ContinuationToken) { 204 | $options->setContinuationToken($continuationToken); 205 | goto start; 206 | } 207 | } catch (Throwable $exception) { 208 | throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); 209 | } 210 | } 211 | 212 | public function createDirectory(string $path, Config $config): void 213 | { 214 | // this is not supported by Azure 215 | } 216 | 217 | public function setVisibility(string $path, string $visibility): void 218 | { 219 | if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) { 220 | throw UnableToSetVisibility::atLocation($path, 'Azure does not support this operation.'); 221 | } 222 | } 223 | 224 | public function visibility(string $path): FileAttributes 225 | { 226 | throw UnableToRetrieveMetadata::visibility($path, 'Azure does not support visibility'); 227 | } 228 | 229 | public function mimeType(string $path): FileAttributes 230 | { 231 | try { 232 | return $this->fetchMetadata($this->prefixer->prefixPath($path)); 233 | } catch (Throwable $exception) { 234 | throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception); 235 | } 236 | } 237 | 238 | public function lastModified(string $path): FileAttributes 239 | { 240 | try { 241 | return $this->fetchMetadata($this->prefixer->prefixPath($path)); 242 | } catch (Throwable $exception) { 243 | throw UnableToRetrieveMetadata::lastModified($path, $exception->getMessage(), $exception); 244 | } 245 | } 246 | 247 | public function fileSize(string $path): FileAttributes 248 | { 249 | try { 250 | return $this->fetchMetadata($this->prefixer->prefixPath($path)); 251 | } catch (Throwable $exception) { 252 | throw UnableToRetrieveMetadata::fileSize($path, $exception->getMessage(), $exception); 253 | } 254 | } 255 | 256 | public function move(string $source, string $destination, Config $config): void 257 | { 258 | try { 259 | $this->copy($source, $destination, $config); 260 | $this->delete($source); 261 | } catch (Throwable $exception) { 262 | throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); 263 | } 264 | } 265 | 266 | public function write(string $path, string $contents, Config $config): void 267 | { 268 | $this->upload($path, $contents, $config); 269 | } 270 | 271 | public function writeStream(string $path, $contents, Config $config): void 272 | { 273 | $this->upload($path, $contents, $config); 274 | } 275 | 276 | /** 277 | * @param string|resource $contents 278 | */ 279 | private function upload(string $destination, $contents, Config $config): void 280 | { 281 | $resolved = $this->prefixer->prefixPath($destination); 282 | try { 283 | $options = $this->getOptionsFromConfig($config); 284 | 285 | if (empty($options->getContentType())) { 286 | $options->setContentType($this->mimeTypeDetector->detectMimeType($resolved, $contents)); 287 | } 288 | 289 | $this->client->createBlockBlob( 290 | $this->container, 291 | $resolved, 292 | $contents, 293 | $options 294 | ); 295 | } catch (Throwable $exception) { 296 | throw UnableToWriteFile::atLocation($destination, $exception->getMessage(), $exception); 297 | } 298 | } 299 | 300 | private function fetchMetadata(string $path): FileAttributes 301 | { 302 | return $this->normalizeBlobProperties( 303 | $path, 304 | $this->client->getBlobProperties($this->container, $path)->getProperties() 305 | ); 306 | } 307 | 308 | private function getOptionsFromConfig(Config $config): CreateBlockBlobOptions 309 | { 310 | $options = new CreateBlockBlobOptions(); 311 | 312 | foreach (self::META_OPTIONS as $option) { 313 | $setting = $config->get($option, '___NOT__SET___'); 314 | 315 | if ($setting === '___NOT__SET___') { 316 | continue; 317 | } 318 | 319 | call_user_func([$options, "set$option"], $setting); 320 | } 321 | 322 | $mimeType = $config->get('mimetype'); 323 | 324 | if ($mimeType !== null) { 325 | $options->setContentType($mimeType); 326 | } 327 | 328 | return $options; 329 | } 330 | 331 | private function normalizeBlobProperties(string $path, BlobProperties $properties): FileAttributes 332 | { 333 | return new FileAttributes( 334 | $path, 335 | $properties->getContentLength(), 336 | null, 337 | $properties->getLastModified()->getTimestamp(), 338 | $properties->getContentType(), 339 | ['md5_checksum' => $properties->getContentMD5()] 340 | ); 341 | } 342 | 343 | public function publicUrl(string $path, Config $config): string 344 | { 345 | $location = $this->prefixer->prefixPath($path); 346 | 347 | return $this->client->getBlobUrl($this->container, $location); 348 | } 349 | 350 | public function checksum(string $path, Config $config): string 351 | { 352 | $algo = $config->get('checksum_algo', 'md5'); 353 | 354 | if ($algo !== 'md5') { 355 | throw new ChecksumAlgoIsNotSupported(); 356 | } 357 | 358 | try { 359 | $metadata = $this->fetchMetadata($this->prefixer->prefixPath($path)); 360 | $checksum = $metadata->extraMetadata()['md5_checksum'] ?? '__not_specified'; 361 | } catch (Throwable $exception) { 362 | throw new UnableToProvideChecksum($exception->getMessage(), $path, $exception); 363 | } 364 | 365 | if ($checksum === '__not_specified') { 366 | throw new UnableToProvideChecksum('No checksum provided in metadata', $path); 367 | } 368 | 369 | return bin2hex(base64_decode($checksum)); 370 | } 371 | 372 | public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string 373 | { 374 | if ( ! $this->serviceSettings instanceof StorageServiceSettings) { 375 | throw UnableToGenerateTemporaryUrl::noGeneratorConfigured( 376 | $path, 377 | 'The $serviceSettings constructor parameter must be set to generate temporary URLs.', 378 | ); 379 | } 380 | 381 | try { 382 | $sas = new BlobSharedAccessSignatureHelper($this->serviceSettings->getName(), $this->serviceSettings->getKey()); 383 | $baseUrl = $this->publicUrl($path, $config); 384 | $resourceName = $this->container . '/' . ltrim($this->prefixer->prefixPath($path), '/'); 385 | $token = $sas->generateBlobServiceSharedAccessSignatureToken( 386 | Resources::RESOURCE_TYPE_BLOB, 387 | $resourceName, 388 | 'r', // read 389 | DateTime::createFromInterface($expiresAt), 390 | $config->get('signed_start', ''), 391 | $config->get('signed_ip', ''), 392 | $config->get('signed_protocol', 'https'), 393 | $config->get('signed_identifier', ''), 394 | $config->get('cache_control', ''), 395 | $config->get('content_disposition', $config->get('content_deposition', '')), 396 | $config->get('content_encoding', ''), 397 | $config->get('content_language', ''), 398 | $config->get('content_type', ''), 399 | ); 400 | 401 | return "$baseUrl?$token"; 402 | } catch (Throwable $exception) { 403 | throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); 404 | } 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2024 Frank de Jonge 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/flysystem-azure-blob-storage", 3 | "autoload": { 4 | "psr-4": { 5 | "League\\Flysystem\\AzureBlobStorage\\": "" 6 | } 7 | }, 8 | "require": { 9 | "php": "^8.0.2", 10 | "league/flysystem": "^3.10.0", 11 | "microsoft/azure-storage-blob": "^1.1" 12 | }, 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Frank de Jonge", 17 | "email": "info@frankdejonge.nl" 18 | } 19 | ], 20 | "abandoned": "azure-oss/storage-blob-flysystem" 21 | } 22 | --------------------------------------------------------------------------------