├── .github └── workflows │ └── php.yml ├── .gitignore ├── LICENSE ├── composer.json ├── phpunit.xml ├── readme.md ├── src ├── BunnyCDNAdapter.php ├── BunnyCDNClient.php ├── BunnyCDNRegion.php ├── Exceptions │ ├── BunnyCDNException.php │ └── NotFoundException.php ├── Util.php └── WriteBatchFile.php └── tests ├── ClientDI_Example.php ├── ClientTest.php ├── FlysystemAdapterTest.php ├── MockClient.php ├── PrefixTest.php └── UtilityClassTest.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master, v2, v3 ] 6 | pull_request: 7 | branches: [ master, v2, v3 ] 8 | 9 | jobs: 10 | build: 11 | runs-on: 'ubuntu-latest' 12 | strategy: 13 | matrix: 14 | php-versions: [ '8.0', '8.1', '8.2' ] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Setup PHP with PCOV 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: ${{ matrix.php-versions }} 23 | coverage: pcov 24 | 25 | - name: Validate composer.json and composer.lock 26 | run: composer validate --strict 27 | 28 | - name: Cache Composer packages 29 | id: composer-cache 30 | uses: actions/cache@v2 31 | with: 32 | path: vendor 33 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-php- 36 | 37 | - name: Install dependencies 38 | run: composer install --prefer-dist --no-progress --no-interaction 39 | 40 | - name: Run linter 41 | run: ./vendor/bin/pint -v --test 42 | 43 | - name: Run tests 44 | run: vendor/bin/phpunit -c ./phpunit.xml 45 | 46 | - uses: php-actions/phpstan@v3 47 | with: 48 | path: src/ 49 | 50 | - name: Upload to PCOV 51 | run: bash <(curl -s https://codecov.io/bash) 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .phpunit.cache/ 4 | .phpunit.result.cache 5 | bin 6 | clover.xml 7 | composer.lock 8 | coverage 9 | coverage.xml 10 | report/ 11 | tests/ClientDI.php 12 | tests/files 13 | vendor 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Platform 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "platformcommunity/flysystem-bunnycdn", 3 | "description": "Flysystem adapter for BunnyCDN", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Alex", 8 | "email": "alex@platformapp.io" 9 | } 10 | ], 11 | "require": { 12 | "league/flysystem": "^3.16", 13 | "ext-json": "*", 14 | "guzzlehttp/guzzle": "^7.4", 15 | "league/mime-type-detection": "^1.11" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "@stable", 19 | "league/flysystem-adapter-test-utilities": "^3", 20 | "league/flysystem-memory": "^3.0", 21 | "league/flysystem-path-prefixing": "^3.3", 22 | "fzaninotto/faker": "^1.5", 23 | "laravel/pint": "^0.2.3" 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "PlatformCommunity\\Flysystem\\BunnyCDN\\Tests\\": "tests/" 28 | } 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "PlatformCommunity\\Flysystem\\BunnyCDN\\Tests\\": "tests/", 33 | "PlatformCommunity\\Flysystem\\BunnyCDN\\": "src/" 34 | } 35 | }, 36 | "extra": { 37 | "branch-alias": { 38 | "dev-master": "3.x-dev" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests/ 21 | 22 | 23 | 24 | 25 | 26 | ./src/ 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Bunny CDN Logo 2 | 3 | # Flysystem Adapter for BunnyCDN Storage 4 | [![Build Status - Flysystem v2](https://img.shields.io/github/actions/workflow/status/PlatformCommunity/flysystem-bunnycdn/php.yml?branch=v2&label=Flysystem%20v2)](https://github.com/PlatformCommunity/flysystem-bunnycdn/actions) [![Build Status - Flysystem v3](https://img.shields.io/github/actions/workflow/status/PlatformCommunity/flysystem-bunnycdn/php.yml?branch=v3&label=Flysystem%20v3)](https://github.com/PlatformCommunity/flysystem-bunnycdn/actions)
[![Codecov](https://img.shields.io/codecov/c/github/PlatformCommunity/flysystem-bunnycdn)](https://codecov.io/gh/PlatformCommunity/flysystem-bunnycdn) [![Packagist Version](https://img.shields.io/packagist/v/platformcommunity/flysystem-bunnycdn)](https://packagist.org/packages/platformcommunity/flysystem-bunnycdn) ![Minimum PHP Version: 7.4](https://img.shields.io/badge/php-min%207.4-important) [![Licence: MIT](https://img.shields.io/packagist/l/platformcommunity/flysystem-bunnycdn)](https://github.com/PlatformCommunity/flysystem-bunnycdn/blob/master/LICENSE) [![Downloads](https://img.shields.io/packagist/dm/platformcommunity/flysystem-bunnycdn)](https://packagist.org/packages/platformcommunity/flysystem-bunnycdn) 5 | 6 | 7 | ## Installation 8 | 9 | To install `flysystem-bunnycdn`, require the package with no version constraint. This should match the `flysystem-bunnycdn` version with your version of FlySystem (v2, v3 etc). 10 | 11 | ```bash 12 | composer require platformcommunity/flysystem-bunnycdn "*" 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```php 18 | use League\Flysystem\Filesystem; 19 | use PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNAdapter; 20 | use PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNClient; 21 | use PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNRegion; 22 | 23 | $adapter = new BunnyCDNAdapter( 24 | new BunnyCDNClient( 25 | 'storage-zone', 26 | 'api-key', 27 | BunnyCDNRegion::FALKENSTEIN 28 | ) 29 | ); 30 | 31 | $filesystem = new Filesystem($adapter); 32 | ``` 33 | 34 | ### Usage with Pull Zones 35 | 36 | To have BunnyCDN adapter publish to a public CDN location, you have to a "Pull Zone" connected to your BunnyCDN Storage Zone. Add the full URL prefix of your Pull Zone (including `http://`/`https://`) to the BunnyCDNAdapter parameter like shown below. 37 | 38 | 39 | ```php 40 | $adapter = new BunnyCDNAdapter( 41 | new BunnyCDNClient( 42 | 'storage-zone', 43 | 'api-key', 44 | BunnyCDNRegion::FALKENSTEIN 45 | ), 46 | 'https://testing.b-cdn.net/' # Pull Zone URL 47 | ); 48 | $filesystem = new Filesystem($adapter); 49 | ``` 50 | 51 | _Note: You can also use your own domain name if it's configured in the pull zone._ 52 | 53 | Once you add your pull zone, you can use the `->getUrl($path)`, or in Laravel, the `->url($path)` command to get the fully qualified public URL of your BunnyCDN assets. 54 | 55 | ## Usage in Laravel 9 56 | To add BunnyCDN adapter as a custom storage adapter in Laravel 9, install using the `v3` composer installer. 57 | 58 | ```bash 59 | composer require platformcommunity/flysystem-bunnycdn "^3.0" 60 | ``` 61 | 62 | Next, install the adapter to your `AppServiceProvider` to give Laravel's FileSystem knowledge of the BunnyCDN adapter. 63 | 64 | ```php 65 | /** 66 | * Bootstrap any application services. 67 | * 68 | * @return void 69 | */ 70 | public function boot() 71 | { 72 | Storage::extend('bunnycdn', function ($app, $config) { 73 | $adapter = new BunnyCDNAdapter( 74 | new BunnyCDNClient( 75 | $config['storage_zone'], 76 | $config['api_key'], 77 | $config['region'] 78 | ), 79 | $config['pull_zone'] 80 | ); 81 | 82 | return new FilesystemAdapter( 83 | new Filesystem($adapter, $config), 84 | $adapter, 85 | $config 86 | ); 87 | }); 88 | } 89 | ``` 90 | 91 | Finally, add the `bunnycdn` driver into your `config/filesystems.php` configuration file. 92 | 93 | ```php 94 | ... 95 | 96 | 'bunnycdn' => [ 97 | 'driver' => 'bunnycdn', 98 | 'storage_zone' => env('BUNNYCDN_STORAGE_ZONE'), 99 | 'pull_zone' => env('BUNNYCDN_PULL_ZONE'), 100 | 'api_key' => env('BUNNYCDN_API_KEY'), 101 | 'region' => env('BUNNYCDN_REGION', \PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNRegion::DEFAULT) 102 | ], 103 | 104 | ... 105 | ``` 106 | 107 | Lastly, populate your `BUNNYCDN_STORAGE_ZONE`, `BUNNYCDN_API_KEY` `BUNNYCDN_REGION` variables in your `.env` file. 108 | 109 | ```dotenv 110 | BUNNYCDN_STORAGE_ZONE=testing_storage_zone 111 | BUNNYCDN_PULL_ZONE=https://testing.b-cdn.net 112 | BUNNYCDN_API_KEY="api-key" 113 | # BUNNYCDN_REGION=uk 114 | ``` 115 | 116 | After that, you can use the `bunnycdn` disk in Laravel 9. 117 | 118 | ```php 119 | Storage::disk('bunnycdn')->put('index.html', 'Hello World'); 120 | 121 | return response(Storage::disk('bunnycdn')->get('index.html')); 122 | ``` 123 | 124 | _Note: You may have to run `php artisan config:clear` in order for your configuration to be refreshed if your app is running with a config cache driver / production mode._ 125 | 126 | ## Regions 127 | 128 | For a full region list, please visit the [BunnyCDN API documentation page](https://docs.bunny.net/reference/storage-api#storage-endpoints). 129 | 130 | `flysystem-bunnycdn` also comes with constants for each region located within `PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNRegion`. 131 | 132 | ### List of Regions 133 | 134 | ```php 135 | # Europe 136 | BunnyCDNRegion::FALKENSTEIN = 'de'; 137 | BunnyCDNRegion::STOCKHOLM = 'se'; 138 | 139 | # United Kingdom 140 | BunnyCDNRegion::UNITED_KINGDOM = 'uk'; 141 | 142 | # USA 143 | BunnyCDNRegion::NEW_YORK = 'ny'; 144 | BunnyCDNRegion::LOS_ANGELAS = 'la'; 145 | 146 | # SEA 147 | BunnyCDNRegion::SINGAPORE = 'sg'; 148 | 149 | # Oceania 150 | BunnyCDNRegion::SYDNEY = 'syd'; 151 | 152 | # Africa 153 | BunnyCDNRegion::JOHANNESBURG = 'jh'; 154 | 155 | # South America 156 | BunnyCDNRegion::BRAZIL = 'br'; 157 | ``` 158 | 159 | ## Contributing 160 | 161 | Pull requests welcome. Please feel free to lodge any issues as discussion points. 162 | 163 | ## Development 164 | 165 | Most of the understanding of how the Flysystem Adapter for BunnyCDN works comes from `tests/`. If you want to complete tests against a live BunnyCDN endpoint, copy the `tests/ClientDI_Example.php` to `tests/ClientDI.php` and insert your credentials into there. You can then run the whole suite by running `vendor/bin/phpunit`, or against a specific file by running `vendor/bin/phpunit --bootstrap tests/ClientDI.php tests/ClientTest.php`. 166 | 167 | 168 | ## Licence 169 | 170 | The Flysystem adapter for Bunny.net is licensed under [MIT](https://github.com/PlatformCommunity/flysystem-bunnycdn/blob/master/LICENSE). 171 | -------------------------------------------------------------------------------- /src/BunnyCDNAdapter.php: -------------------------------------------------------------------------------- 1 | 2 && (string) \func_get_arg(2) !== '') { 43 | throw new \RuntimeException('PrefixPath is no longer supported directly. Use PathPrefixedAdapter instead: https://flysystem.thephpleague.com/docs/adapter/path-prefixing/'); 44 | } 45 | } 46 | 47 | /** 48 | * @param $source 49 | * @param $destination 50 | * @param Config $config 51 | * @return void 52 | */ 53 | public function copy($source, $destination, Config $config): void 54 | { 55 | try { 56 | $sourceLength = \strlen($source); 57 | 58 | foreach ($this->getFiles($source) as $file) { 59 | $this->copyFile($file, $destination.\substr($file, $sourceLength), $config); 60 | } 61 | } catch (UnableToReadFile|UnableToWriteFile $exception) { 62 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 63 | } 64 | } 65 | 66 | /** 67 | * @param $path 68 | * @param $contents 69 | * @param Config $config 70 | */ 71 | public function write($path, $contents, Config $config): void 72 | { 73 | try { 74 | $this->client->upload($path, $contents); 75 | // @codeCoverageIgnoreStart 76 | } catch (Exceptions\BunnyCDNException $e) { 77 | throw UnableToWriteFile::atLocation($path, $e->getMessage()); 78 | } 79 | // @codeCoverageIgnoreEnd 80 | } 81 | 82 | /** 83 | * @param $path 84 | * @return string 85 | */ 86 | public function read($path): string 87 | { 88 | try { 89 | return $this->client->download($path); 90 | // @codeCoverageIgnoreStart 91 | } catch (Exceptions\BunnyCDNException $e) { 92 | throw UnableToReadFile::fromLocation($path, $e->getMessage()); 93 | } 94 | // @codeCoverageIgnoreEnd 95 | } 96 | 97 | /** 98 | * @param string $path 99 | * @param bool $deep 100 | * @return iterable 101 | */ 102 | public function listContents(string $path, bool $deep): iterable 103 | { 104 | try { 105 | $entries = $this->client->list($path); 106 | // @codeCoverageIgnoreStart 107 | } catch (Exceptions\BunnyCDNException $e) { 108 | throw UnableToRetrieveMetadata::create($path, 'folder', $e->getMessage()); 109 | } 110 | // @codeCoverageIgnoreEnd 111 | 112 | foreach ($entries as $item) { 113 | $content = $this->normalizeObject($item); 114 | yield $content; 115 | 116 | if ($deep && $content instanceof DirectoryAttributes) { 117 | foreach ($this->listContents($content->path(), $deep) as $deepItem) { 118 | yield $deepItem; 119 | } 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * @param array $bunny_file_array 126 | * @return StorageAttributes 127 | */ 128 | protected function normalizeObject(array $bunny_file_array): StorageAttributes 129 | { 130 | $normalised_path = Util::normalizePath( 131 | Util::replaceFirst( 132 | $bunny_file_array['StorageZoneName'].'/', 133 | '/', 134 | $bunny_file_array['Path'].$bunny_file_array['ObjectName'] 135 | ) 136 | ); 137 | 138 | return match ($bunny_file_array['IsDirectory']) { 139 | true => new DirectoryAttributes( 140 | $normalised_path 141 | ), 142 | false => new FileAttributes( 143 | $normalised_path, 144 | $bunny_file_array['Length'], 145 | Visibility::PUBLIC, 146 | self::parse_bunny_timestamp($bunny_file_array['LastChanged']), 147 | $bunny_file_array['ContentType'] ?: $this->detectMimeType($bunny_file_array['Path'].$bunny_file_array['ObjectName']), 148 | $this->extractExtraMetadata($bunny_file_array) 149 | ) 150 | }; 151 | } 152 | 153 | /** 154 | * @param array $bunny_file_array 155 | * @return array 156 | */ 157 | private function extractExtraMetadata(array $bunny_file_array): array 158 | { 159 | return [ 160 | 'type' => $bunny_file_array['IsDirectory'] ? 'dir' : 'file', 161 | 'dirname' => Util::splitPathIntoDirectoryAndFile($bunny_file_array['Path'])['dir'], 162 | 'guid' => $bunny_file_array['Guid'], 163 | 'object_name' => $bunny_file_array['ObjectName'], 164 | 'timestamp' => self::parse_bunny_timestamp($bunny_file_array['LastChanged']), 165 | 'server_id' => $bunny_file_array['ServerId'], 166 | 'user_id' => $bunny_file_array['UserId'], 167 | 'date_created' => $bunny_file_array['DateCreated'], 168 | 'storage_zone_name' => $bunny_file_array['StorageZoneName'], 169 | 'storage_zone_id' => $bunny_file_array['StorageZoneId'], 170 | 'checksum' => $bunny_file_array['Checksum'], 171 | 'replicated_zones' => $bunny_file_array['ReplicatedZones'], 172 | ]; 173 | } 174 | 175 | /** 176 | * Detects the mime type from the provided file path 177 | * 178 | * @param string $path 179 | * @return string 180 | */ 181 | public function detectMimeType(string $path): string 182 | { 183 | try { 184 | $detector = new FinfoMimeTypeDetector(); 185 | $mimeType = $detector->detectMimeTypeFromPath($path); 186 | 187 | if (! $mimeType) { 188 | return $detector->detectMimeTypeFromBuffer(stream_get_contents($this->readStream($path), 80)); 189 | } 190 | 191 | return $mimeType; 192 | } catch (Exception) { 193 | return ''; 194 | } 195 | } 196 | 197 | /** 198 | * @param $path 199 | * @param $contents 200 | * @param Config $config 201 | * @return void 202 | */ 203 | public function writeStream($path, $contents, Config $config): void 204 | { 205 | $this->write($path, stream_get_contents($contents), $config); 206 | } 207 | 208 | /** 209 | * @param WriteBatchFile[] $writeBatches 210 | * @param Config $config 211 | * @return void 212 | */ 213 | public function writeBatch(array $writeBatches, Config $config): void 214 | { 215 | $concurrency = (int) $config->get('concurrency', 50); 216 | 217 | foreach (\array_chunk($writeBatches, $concurrency) as $batch) { 218 | $requests = function () use ($batch) { 219 | /** @var WriteBatchFile $file */ 220 | foreach ($batch as $file) { 221 | yield $this->client->getUploadRequest($file->targetPath, \file_get_contents($file->localPath)); 222 | } 223 | }; 224 | 225 | $pool = new Pool($this->client->guzzleClient, $requests(), [ 226 | 'concurrency' => $concurrency, 227 | 'rejected' => function (RequestException|RuntimeException $reason, int $index) { 228 | throw UnableToWriteFile::atLocation($index, $reason->getMessage()); 229 | }, 230 | ]); 231 | 232 | $pool->promise()->wait(); 233 | } 234 | } 235 | 236 | /** 237 | * @param $path 238 | * @return resource 239 | * 240 | * @throws UnableToReadFile 241 | */ 242 | public function readStream($path) 243 | { 244 | try { 245 | return $this->client->stream($path); 246 | // @codeCoverageIgnoreStart 247 | } catch (Exceptions\BunnyCDNException|Exceptions\NotFoundException $e) { 248 | throw UnableToReadFile::fromLocation($path, $e->getMessage()); 249 | } 250 | // @codeCoverageIgnoreEnd 251 | } 252 | 253 | /** 254 | * @throws UnableToDeleteDirectory 255 | * @throws FilesystemException 256 | */ 257 | public function deleteDirectory(string $path): void 258 | { 259 | try { 260 | $this->client->delete( 261 | rtrim($path, '/').'/' 262 | ); 263 | // @codeCoverageIgnoreStart 264 | } catch (NotFoundException) { 265 | // nth 266 | } catch (Exceptions\BunnyCDNException $e) { 267 | throw UnableToDeleteDirectory::atLocation($path, $e->getMessage()); 268 | } 269 | // @codeCoverageIgnoreEnd 270 | } 271 | 272 | /** 273 | * @throws UnableToCreateDirectory 274 | * @throws FilesystemException 275 | */ 276 | public function createDirectory(string $path, Config $config): void 277 | { 278 | try { 279 | $this->client->make_directory($path); 280 | // @codeCoverageIgnoreStart 281 | } catch (Exceptions\BunnyCDNException $e) { 282 | // Lol apparently this is "idempotent" but there's an exception... Sure whatever.. 283 | match ($e->getMessage()) { 284 | 'Directory already exists' => '', 285 | default => throw UnableToCreateDirectory::atLocation($path, $e->getMessage()) 286 | }; 287 | } 288 | // @codeCoverageIgnoreEnd 289 | } 290 | 291 | /** 292 | * @throws InvalidVisibilityProvided 293 | * @throws FilesystemException 294 | */ 295 | public function setVisibility(string $path, string $visibility): void 296 | { 297 | throw UnableToSetVisibility::atLocation($path, 'BunnyCDN does not support visibility'); 298 | } 299 | 300 | /** 301 | * @throws UnableToRetrieveMetadata 302 | */ 303 | public function visibility(string $path): FileAttributes 304 | { 305 | try { 306 | return new FileAttributes($this->getObject($path)->path(), null, $this->pullzone_url ? 'public' : 'private'); 307 | } catch (UnableToReadFile|TypeError $e) { 308 | throw new UnableToRetrieveMetadata($e->getMessage()); 309 | } 310 | } 311 | 312 | /** 313 | * @param string $path 314 | * @return FileAttributes 315 | * 316 | * @codeCoverageIgnore 317 | */ 318 | public function mimeType(string $path): FileAttributes 319 | { 320 | try { 321 | $object = $this->getObject($path); 322 | 323 | if ($object instanceof DirectoryAttributes) { 324 | throw new TypeError(); 325 | } 326 | 327 | /** @var FileAttributes $object */ 328 | if (! $object->mimeType()) { 329 | $mimeType = $this->detectMimeType($path); 330 | 331 | if (! $mimeType || $mimeType === 'text/plain') { // Really not happy about this being required by Fly's Test case 332 | throw new UnableToRetrieveMetadata('Unknown Mimetype'); 333 | } 334 | 335 | return new FileAttributes( 336 | $path, 337 | null, 338 | null, 339 | null, 340 | $mimeType 341 | ); 342 | } 343 | 344 | return $object; 345 | } catch (UnableToReadFile $e) { 346 | throw new UnableToRetrieveMetadata($e->getMessage()); 347 | } catch (TypeError) { 348 | throw new UnableToRetrieveMetadata('Cannot retrieve mimeType of folder'); 349 | } 350 | } 351 | 352 | /** 353 | * @param string $path 354 | * @return mixed 355 | */ 356 | protected function getObject(string $path = ''): StorageAttributes 357 | { 358 | $directory = pathinfo($path, PATHINFO_DIRNAME); 359 | $list = (new DirectoryListing($this->listContents($directory, false))) 360 | ->filter(function (StorageAttributes $item) use ($path) { 361 | return Util::normalizePath($item->path()) === $path; 362 | })->toArray(); 363 | 364 | if (count($list) === 1) { 365 | return $list[0]; 366 | } 367 | 368 | if (count($list) > 1) { 369 | // @codeCoverageIgnoreStart 370 | throw UnableToReadFile::fromLocation($path, 'More than one file was returned for path:"'.$path.'", contact package author.'); 371 | // @codeCoverageIgnoreEnd 372 | } 373 | 374 | throw UnableToReadFile::fromLocation($path, 'Error 404:"'.$path.'"'); 375 | } 376 | 377 | /** 378 | * @param string $path 379 | * @return FileAttributes 380 | */ 381 | public function lastModified(string $path): FileAttributes 382 | { 383 | try { 384 | return $this->getObject($path); 385 | } catch (UnableToReadFile $e) { 386 | throw new UnableToRetrieveMetadata($e->getMessage()); 387 | } catch (TypeError) { 388 | throw new UnableToRetrieveMetadata('Last Modified only accepts files as parameters, not directories'); 389 | } 390 | } 391 | 392 | /** 393 | * @param string $path 394 | * @return FileAttributes 395 | */ 396 | public function fileSize(string $path): FileAttributes 397 | { 398 | try { 399 | return $this->getObject($path); 400 | } catch (UnableToReadFile $e) { 401 | throw new UnableToRetrieveMetadata($e->getMessage()); 402 | } catch (TypeError) { 403 | throw new UnableToRetrieveMetadata('Cannot retrieve size of folder'); 404 | } 405 | } 406 | 407 | /** 408 | * @throws UnableToMoveFile 409 | * @throws FilesystemException 410 | */ 411 | public function move(string $source, string $destination, Config $config): void 412 | { 413 | if ($source === $destination) { 414 | return; 415 | } 416 | 417 | try { 418 | /** @var array $files */ 419 | $files = iterator_to_array($this->getFiles($source)); 420 | 421 | $sourceLength = \strlen($source); 422 | 423 | foreach ($files as $file) { 424 | $this->moveFile($file, $destination.\substr($file, $sourceLength), $config); 425 | } 426 | } catch (UnableToReadFile $e) { 427 | throw new UnableToMoveFile($e->getMessage()); 428 | } 429 | } 430 | 431 | private function getFiles(string $source): iterable 432 | { 433 | $contents = iterator_to_array($this->listContents($source, true)); 434 | 435 | if (\count($contents) === 0) { 436 | yield $source; 437 | 438 | return; 439 | } 440 | 441 | /** @var StorageAttributes $entry */ 442 | foreach ($contents as $entry) { 443 | if ($entry->isFile() === false) { 444 | continue; 445 | } 446 | 447 | yield $entry->path(); 448 | } 449 | } 450 | 451 | private function moveFile(string $source, string $destination, Config $config): void 452 | { 453 | $this->copyFile($source, $destination, $config); 454 | $this->delete($source); 455 | } 456 | 457 | private function copyFile(string $source, string $destination, Config $config): void 458 | { 459 | $this->write($destination, $this->read($source), $config); 460 | } 461 | 462 | /** 463 | * @param $path 464 | * @return void 465 | */ 466 | public function delete($path): void 467 | { 468 | // if path is empty or ends with /, it's a directory. 469 | if (empty($path) || str_ends_with($path, '/')) { 470 | throw UnableToDeleteFile::atLocation($path, 'Deletion of directories prevented.'); 471 | } 472 | 473 | try { 474 | $this->client->delete($path); 475 | // @codeCoverageIgnoreStart 476 | } catch (NotFoundException) { 477 | // nth 478 | } catch (Exceptions\BunnyCDNException $e) { 479 | throw UnableToDeleteFile::atLocation($path, $e->getMessage()); 480 | } 481 | // @codeCoverageIgnoreEnd 482 | } 483 | 484 | /** 485 | * @throws UnableToCheckExistence 486 | */ 487 | public function directoryExists(string $path): bool 488 | { 489 | return $this->exists(StorageAttributes::TYPE_DIRECTORY, $path); 490 | } 491 | 492 | /** 493 | * @param string $path 494 | * @return bool 495 | */ 496 | public function fileExists(string $path): bool 497 | { 498 | return $this->exists(StorageAttributes::TYPE_FILE, $path); 499 | } 500 | 501 | /** 502 | * @param string $path 503 | * @param Config $config 504 | * @return string 505 | */ 506 | public function checksum(string $path, Config $config): string 507 | { 508 | // for compatibility reasons, the default checksum algorithm is md5 509 | $algo = $config->get('checksum_algo', 'md5'); 510 | 511 | if ($algo !== 'sha256') { 512 | return $this->calculateChecksumFromStream($path, $config); 513 | } 514 | 515 | try { 516 | $file = $this->getObject($path); 517 | } catch (UnableToReadFile $exception) { 518 | throw new UnableToProvideChecksum($exception->reason(), $path, $exception); 519 | } 520 | 521 | $metaData = $file->extraMetadata(); 522 | 523 | if (empty($metaData['checksum']) || ! is_string($metaData['checksum'])) { 524 | throw new UnableToProvideChecksum('Checksum not available.', $path); 525 | } 526 | 527 | return \strtolower($metaData['checksum']); 528 | } 529 | 530 | /** 531 | * @deprecated use publicUrl instead 532 | * 533 | * @param string $path 534 | * @return string 535 | * @codeCoverageIgnore 536 | * @noinspection PhpUnused 537 | */ 538 | public function getUrl(string $path): string 539 | { 540 | return $this->publicUrl($path, new Config()); 541 | } 542 | 543 | /** 544 | * @param string $path 545 | * @param Config $config 546 | * @return string 547 | */ 548 | public function publicUrl(string $path, Config $config): string 549 | { 550 | if ($this->pullzone_url === '') { 551 | throw new RuntimeException('In order to get a visible URL for a BunnyCDN object, you must pass the "pullzone_url" parameter to the BunnyCDNAdapter.'); 552 | } 553 | 554 | return rtrim($this->pullzone_url, '/').'/'.ltrim($path, '/'); 555 | } 556 | 557 | private static function parse_bunny_timestamp(string $timestamp): int 558 | { 559 | return (date_create_from_format('Y-m-d\TH:i:s.u', $timestamp) ?: date_create_from_format('Y-m-d\TH:i:s', $timestamp))->getTimestamp(); 560 | } 561 | 562 | private function exists(string $type, string $path): bool 563 | { 564 | $list = new DirectoryListing($this->listContents( 565 | Util::splitPathIntoDirectoryAndFile($path)['dir'], 566 | false 567 | )); 568 | 569 | $count = $list->filter(function (StorageAttributes $item) use ($path, $type) { 570 | return $item->type() === $type && Util::normalizePath($item->path()) === Util::normalizePath($path); 571 | })->toArray(); 572 | 573 | return (bool) count($count); 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /src/BunnyCDNClient.php: -------------------------------------------------------------------------------- 1 | guzzleClient = new Guzzle(); 22 | } 23 | 24 | private static function get_base_url($region): string 25 | { 26 | return match (strtolower($region)) { 27 | BunnyCDNRegion::NEW_YORK => 'https://ny.storage.bunnycdn.com/', 28 | BunnyCDNRegion::LOS_ANGELAS => 'https://la.storage.bunnycdn.com/', 29 | BunnyCDNRegion::SINGAPORE => 'https://sg.storage.bunnycdn.com/', 30 | BunnyCDNRegion::SYDNEY => 'https://syd.storage.bunnycdn.com/', 31 | BunnyCDNRegion::UNITED_KINGDOM => 'https://uk.storage.bunnycdn.com/', 32 | BunnyCDNRegion::STOCKHOLM => 'https://se.storage.bunnycdn.com/', 33 | BunnyCDNRegion::BRAZIL => 'https://br.storage.bunnycdn.com/', 34 | BunnyCDNRegion::JOHANNESBURG => 'https://jh.storage.bunnycdn.com/', 35 | default => 'https://storage.bunnycdn.com/' 36 | }; 37 | } 38 | 39 | public function createRequest(string $path, string $method = 'GET', array $headers = [], $body = null): Request 40 | { 41 | return new Request( 42 | $method, 43 | self::get_base_url($this->region).Util::normalizePath('/'.$this->storage_zone_name.'/').$path, 44 | array_merge([ 45 | 'Accept' => '*/*', 46 | 'AccessKey' => $this->api_key, 47 | ], $headers), 48 | $body 49 | ); 50 | } 51 | 52 | /** 53 | * @throws ClientExceptionInterface 54 | */ 55 | private function request(Request $request, array $options = []): mixed 56 | { 57 | $contents = $this->guzzleClient->send($request, $options)->getBody()->getContents(); 58 | 59 | return json_decode($contents, true) ?? $contents; 60 | } 61 | 62 | /** 63 | * @param string $path 64 | * @return array 65 | * 66 | * @throws NotFoundException|BunnyCDNException 67 | */ 68 | public function list(string $path): array 69 | { 70 | try { 71 | $listing = $this->request($this->createRequest(Util::normalizePath($path).'/')); 72 | 73 | // Throw an exception if we don't get back an array 74 | if (! is_array($listing)) { 75 | throw new NotFoundException('File is not a directory'); 76 | } 77 | 78 | return array_map(function ($bunny_cdn_item) { 79 | return $bunny_cdn_item; 80 | }, $listing); 81 | // @codeCoverageIgnoreStart 82 | } catch (GuzzleException $e) { 83 | throw match ($e->getCode()) { 84 | 404 => new NotFoundException($e->getMessage()), 85 | default => new BunnyCDNException($e->getMessage()) 86 | }; 87 | } 88 | // @codeCoverageIgnoreEnd 89 | } 90 | 91 | /** 92 | * @param string $path 93 | * @return mixed 94 | * 95 | * @throws BunnyCDNException 96 | * @throws NotFoundException 97 | */ 98 | public function download(string $path): string 99 | { 100 | try { 101 | $content = $this->request($this->createRequest($path.'?download')); 102 | 103 | if (\is_array($content)) { 104 | return \json_encode($content); 105 | } 106 | 107 | return $content; 108 | // @codeCoverageIgnoreStart 109 | } catch (GuzzleException $e) { 110 | throw match ($e->getCode()) { 111 | 404 => new NotFoundException($e->getMessage()), 112 | default => new BunnyCDNException($e->getMessage()) 113 | }; 114 | } 115 | // @codeCoverageIgnoreEnd 116 | } 117 | 118 | /** 119 | * @param string $path 120 | * @return resource|null 121 | * 122 | * @throws BunnyCDNException 123 | * @throws NotFoundException 124 | */ 125 | public function stream(string $path) 126 | { 127 | try { 128 | return $this->guzzleClient->send($this->createRequest($path), ['stream' => true])->getBody()->detach(); 129 | // @codeCoverageIgnoreStart 130 | } catch (GuzzleException $e) { 131 | throw match ($e->getCode()) { 132 | 404 => new NotFoundException($e->getMessage()), 133 | default => new BunnyCDNException($e->getMessage()) 134 | }; 135 | } 136 | // @codeCoverageIgnoreEnd 137 | } 138 | 139 | public function getUploadRequest(string $path, $contents): Request 140 | { 141 | return $this->createRequest( 142 | $path, 143 | 'PUT', 144 | [ 145 | 'Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8', 146 | ], 147 | $contents 148 | ); 149 | } 150 | 151 | /** 152 | * @param string $path 153 | * @param $contents 154 | * @return mixed 155 | * 156 | * @throws BunnyCDNException 157 | */ 158 | public function upload(string $path, $contents): mixed 159 | { 160 | try { 161 | return $this->request($this->getUploadRequest($path, $contents)); 162 | // @codeCoverageIgnoreStart 163 | } catch (GuzzleException $e) { 164 | throw new BunnyCDNException($e->getMessage()); 165 | } 166 | // @codeCoverageIgnoreEnd 167 | } 168 | 169 | /** 170 | * @param string $path 171 | * @return mixed 172 | * 173 | * @throws BunnyCDNException 174 | */ 175 | public function make_directory(string $path): mixed 176 | { 177 | try { 178 | return $this->request($this->createRequest(Util::normalizePath($path).'/', 'PUT', [ 179 | 'Content-Length' => 0, 180 | ])); 181 | // @codeCoverageIgnoreStart 182 | } catch (GuzzleException $e) { 183 | throw match ($e->getCode()) { 184 | 400 => new BunnyCDNException('Directory already exists'), 185 | default => new BunnyCDNException($e->getMessage()) 186 | }; 187 | } 188 | // @codeCoverageIgnoreEnd 189 | } 190 | 191 | /** 192 | * @param string $path 193 | * @return mixed 194 | * 195 | * @throws NotFoundException 196 | * @throws BunnyCDNException 197 | */ 198 | public function delete(string $path): mixed 199 | { 200 | try { 201 | return $this->request($this->createRequest($path, 'DELETE')); 202 | // @codeCoverageIgnoreStart 203 | } catch (GuzzleException $e) { 204 | throw match ($e->getCode()) { 205 | 404 => new NotFoundException($e->getMessage()), 206 | default => new BunnyCDNException($e->getMessage()) 207 | }; 208 | } 209 | // @codeCoverageIgnoreEnd 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/BunnyCDNRegion.php: -------------------------------------------------------------------------------- 1 | $file, 25 | 'dir' => $directory, 26 | ]; 27 | } 28 | 29 | /** 30 | * @param $path 31 | * @param bool $isDirectory 32 | * @return false|string|string[] 33 | */ 34 | public static function normalizePath($path, $isDirectory = false) 35 | { 36 | $path = str_replace('\\', '/', $path); 37 | 38 | if ($isDirectory && ! self::endsWith($path, '/')) { 39 | $path .= '/'; 40 | } 41 | 42 | // Remove double slashes 43 | while (strpos($path, '//') !== false) { 44 | $path = str_replace('//', '/', $path); 45 | } 46 | 47 | // Remove the starting slash 48 | if (strpos($path, '/') === 0) { 49 | $path = substr($path, 1); 50 | } 51 | 52 | return $path; 53 | } 54 | 55 | /** 56 | * @codeCoverageIgnore 57 | * 58 | * @param $haystack 59 | * @param $needle 60 | * @return bool 61 | */ 62 | public static function startsWith($haystack, $needle): bool 63 | { 64 | return strpos($haystack, $needle) === 0; 65 | } 66 | 67 | /** 68 | * @param $haystack 69 | * @param $needle 70 | * @return bool 71 | */ 72 | public static function endsWith($haystack, $needle): bool 73 | { 74 | $length = strlen($needle); 75 | if ($length === 0) { 76 | return true; 77 | } 78 | 79 | return substr($haystack, -$length) === $needle; 80 | } 81 | 82 | public static function replaceFirst(string $search, string $replace, string $subject): string 83 | { 84 | $position = strpos($subject, $search); 85 | 86 | if ($position !== false) { 87 | return (string) substr_replace($subject, $replace, $position, strlen($search)); 88 | } 89 | 90 | return $subject; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/WriteBatchFile.php: -------------------------------------------------------------------------------- 1 | client = self::bunnyCDNClient(); 36 | $this->clearStorage(); 37 | } 38 | 39 | private function clearStorage() 40 | { 41 | foreach ($this->client->list('/') as $item) { 42 | try { 43 | $this->client->delete($item['IsDirectory'] ? $item['ObjectName'].'/' : $item['ObjectName']); 44 | } catch (\Exception $exception) { 45 | } // Try our best effort at removing everything from the filesystem 46 | } 47 | 48 | assertEmpty( 49 | $this->client->list('/'), 50 | 'Warning! Bunny Client not emptied out prior to next test. This can be problematic when running the test against production clients' 51 | ); 52 | } 53 | 54 | protected function tearDown(): void 55 | { 56 | $this->clearStorage(); 57 | } 58 | 59 | /** 60 | * @return void 61 | * 62 | * @throws NotFoundException 63 | * @throws BunnyCDNException 64 | */ 65 | public function test_listing_directory() 66 | { 67 | // Arrange 68 | $this->client->make_directory('subfolder'); 69 | $this->client->upload('example_image.png', 'test'); 70 | 71 | $response = $this->client->list('/'); 72 | 73 | $this->assertIsArray($response); 74 | $this->assertCount(2, $response); 75 | } 76 | 77 | /** 78 | * @return void 79 | * 80 | * @throws NotFoundException 81 | * @throws BunnyCDNException 82 | */ 83 | public function test_listing_subdirectory() 84 | { 85 | // Arrange 86 | $this->client->upload('/subfolder/example_image.png', 'test'); 87 | 88 | // Act 89 | $response = $this->client->list('/subfolder'); 90 | 91 | // Assert 92 | $this->assertIsArray($response); 93 | $this->assertCount(1, $response); 94 | } 95 | 96 | /** 97 | * @return void 98 | * 99 | * @throws BunnyCDNException 100 | * @throws NotFoundException 101 | */ 102 | public function test_download_file() 103 | { 104 | $this->client->upload('/test.png', 'test'); 105 | 106 | $response = $this->client->download('/test.png'); 107 | 108 | $this->assertIsString($response); 109 | } 110 | 111 | /** 112 | * @return void 113 | * 114 | * @throws BunnyCDNException 115 | * @throws NotFoundException 116 | */ 117 | public function test_streaming() 118 | { 119 | $this->client->upload('/test.png', str_repeat('example_image_contents', 1024)); 120 | 121 | $stream = $this->client->stream('/test.png'); 122 | 123 | $this->assertIsResource($stream); 124 | 125 | do { 126 | $line = stream_get_line($stream, 512); 127 | $this->assertStringContainsString('example_image_contents', $line); 128 | $this->assertEquals(512, strlen($line)); 129 | } while ($line && strlen($line) > 512); 130 | } 131 | 132 | /** 133 | * @return void 134 | * 135 | * @throws BunnyCDNException 136 | */ 137 | public function test_upload() 138 | { 139 | $response = $this->client->upload('/test_contents.txt', 'testing_contents'); 140 | 141 | $this->assertIsArray($response); 142 | 143 | $this->assertEquals([ 144 | 'HttpCode' => 201, 145 | 'Message' => 'File uploaded.', 146 | ], $response); 147 | } 148 | 149 | /** 150 | * @return void 151 | * 152 | * @throws BunnyCDNException 153 | */ 154 | public function test_make_directory() 155 | { 156 | $response = $this->client->make_directory('/test_dir/'); 157 | 158 | $this->assertIsArray($response); 159 | $this->assertEquals([ 160 | 'HttpCode' => 201, 161 | 'Message' => 'Directory created.', 162 | ], $response); 163 | } 164 | 165 | /** 166 | * @return void 167 | * 168 | * @throws BunnyCDNException 169 | * @throws NotFoundException 170 | */ 171 | public function test_delete_file() 172 | { 173 | $this->client->upload('test_file.txt', '123'); 174 | 175 | $response = $this->client->delete('/test_file.txt'); 176 | 177 | $this->assertIsArray($response); 178 | $this->assertEquals([ 179 | 'HttpCode' => 200, 180 | 'Message' => 'File deleted successfuly.', // ಠ_ಠ Spelling @bunny.net 181 | ], $response); 182 | } 183 | 184 | /** 185 | * @return void 186 | * 187 | * @throws BunnyCDNException 188 | * @throws NotFoundException 189 | */ 190 | public function test_delete_file_not_found() 191 | { 192 | $this->expectException(NotFoundException::class); 193 | $this->client->delete('file_not_found.txt'); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /tests/FlysystemAdapterTest.php: -------------------------------------------------------------------------------- 1 | guzzleClient = new Guzzle([ 65 | 'handler' => function (Request $request) use ($mockedClient) { 66 | $path = $request->getUri()->getPath(); 67 | $method = $request->getMethod(); 68 | 69 | if ($method === 'PUT' && $path === 'destination.txt') { 70 | $mockedClient->filesystem->write('destination.txt', 'text'); 71 | 72 | return new Response(200); 73 | } 74 | 75 | if ($method === 'PUT' && $path === 'destination2.txt') { 76 | $mockedClient->filesystem->write('destination2.txt', 'text2'); 77 | 78 | return new Response(200); 79 | } 80 | 81 | if ($method === 'PUT' && \in_array($path, ['failing.txt', 'failing2.txt'])) { 82 | throw new \RuntimeException('Failed to write file'); 83 | } 84 | 85 | throw new \RuntimeException('Unexpected request: '.$method.' '.$path); 86 | }, 87 | ]); 88 | 89 | return $mockedClient; 90 | } 91 | 92 | public static function createFilesystemAdapter(): FilesystemAdapter 93 | { 94 | return new BunnyCDNAdapter(self::bunnyCDNClient(), static::$publicUrl); 95 | } 96 | 97 | /** 98 | * Skipped 99 | */ 100 | public function setting_visibility(): void 101 | { 102 | $this->markTestSkipped('No visibility support is provided for BunnyCDN'); 103 | } 104 | 105 | public function generating_a_temporary_url(): void 106 | { 107 | $this->markTestSkipped('No temporary URL support is provided for BunnyCDN'); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | public function file_exists_on_directory_is_false(): void 114 | { 115 | $this->runScenario(function () { 116 | $adapter = $this->adapter(); 117 | 118 | $this->assertFalse($adapter->directoryExists('test')); 119 | $adapter->createDirectory('test', new Config()); 120 | $this->assertTrue($adapter->directoryExists('test')); 121 | $this->assertFalse($adapter->fileExists('test')); 122 | }); 123 | } 124 | 125 | /** 126 | * @test 127 | */ 128 | public function directory_exists_on_file_is_false(): void 129 | { 130 | $this->runScenario(function () { 131 | $adapter = $this->adapter(); 132 | 133 | $this->assertFalse($adapter->fileExists('test.txt')); 134 | $adapter->write('test.txt', 'aaa', new Config()); 135 | $this->assertTrue($adapter->fileExists('test.txt')); 136 | $this->assertFalse($adapter->directoryExists('test.txt')); 137 | }); 138 | } 139 | 140 | /** 141 | * @test 142 | */ 143 | public function delete_on_directory_throws_exception(): void 144 | { 145 | $this->runScenario(function () { 146 | $adapter = $this->adapter(); 147 | 148 | $adapter->write( 149 | 'test/text.txt', 150 | 'contents', 151 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 152 | ); 153 | 154 | $this->expectException(UnableToDeleteFile::class); 155 | $adapter->delete('test/'); 156 | }); 157 | } 158 | 159 | /** 160 | * @test 161 | */ 162 | public function delete_with_empty_path_throws_exception(): void 163 | { 164 | $this->runScenario(function () { 165 | $adapter = $this->adapter(); 166 | 167 | $adapter->write( 168 | 'test/text.txt', 169 | 'contents', 170 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 171 | ); 172 | 173 | $this->expectException(UnableToDeleteFile::class); 174 | $adapter->delete(''); 175 | }); 176 | } 177 | 178 | /** 179 | * @test 180 | */ 181 | public function moving_a_folder(): void 182 | { 183 | $this->runScenario(function () { 184 | $adapter = $this->adapter(); 185 | $adapter->write( 186 | 'test/text.txt', 187 | 'contents to be copied', 188 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 189 | ); 190 | $adapter->write( 191 | 'test/2/text.txt', 192 | 'contents to be copied', 193 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 194 | ); 195 | $adapter->move('test', 'destination', new Config()); 196 | $this->assertFalse( 197 | $adapter->fileExists('test/text.txt'), 198 | 'After moving a file should no longer exist in the original location.' 199 | ); 200 | $this->assertFalse( 201 | $adapter->fileExists('test/2/text.txt'), 202 | 'After moving a file should no longer exist in the original location.' 203 | ); 204 | $this->assertTrue( 205 | $adapter->fileExists('destination/text.txt'), 206 | 'After moving, a file should be present at the new location.' 207 | ); 208 | $this->assertTrue( 209 | $adapter->fileExists('destination/2/text.txt'), 210 | 'After moving, a file should be present at the new location.' 211 | ); 212 | $this->assertEquals('contents to be copied', $adapter->read('destination/text.txt')); 213 | $this->assertEquals('contents to be copied', $adapter->read('destination/2/text.txt')); 214 | }); 215 | } 216 | 217 | /** 218 | * @test 219 | */ 220 | public function moving_a_not_existing_folder(): void 221 | { 222 | $this->runScenario(function () { 223 | $adapter = $this->adapter(); 224 | 225 | $this->expectException(UnableToMoveFile::class); 226 | $adapter->move('not_existing_file', 'destination', new Config()); 227 | }); 228 | } 229 | 230 | /** 231 | * @test 232 | */ 233 | public function copying_a_folder(): void 234 | { 235 | $this->runScenario(function () { 236 | $adapter = $this->adapter(); 237 | $adapter->write( 238 | 'test/text.txt', 239 | 'contents to be copied', 240 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 241 | ); 242 | $adapter->write( 243 | 'test/2/text.txt', 244 | 'contents to be copied', 245 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 246 | ); 247 | $adapter->copy('test', 'destination', new Config()); 248 | $this->assertTrue( 249 | $adapter->fileExists('test/text.txt'), 250 | 'After copying a file should exist in the original location.' 251 | ); 252 | $this->assertTrue( 253 | $adapter->fileExists('test/2/text.txt'), 254 | 'After copying a file should exist in the original location.' 255 | ); 256 | $this->assertTrue( 257 | $adapter->fileExists('destination/text.txt'), 258 | 'After copying, a file should be present at the new location.' 259 | ); 260 | $this->assertTrue( 261 | $adapter->fileExists('destination/2/text.txt'), 262 | 'After copying, a file should be present at the new location.' 263 | ); 264 | $this->assertEquals('contents to be copied', $adapter->read('destination/text.txt')); 265 | $this->assertEquals('contents to be copied', $adapter->read('destination/2/text.txt')); 266 | }); 267 | } 268 | 269 | /** 270 | * @test 271 | */ 272 | public function copying_a_not_existing_folder(): void 273 | { 274 | $this->runScenario(function () { 275 | $adapter = $this->adapter(); 276 | 277 | $this->expectException(UnableToCopyFile::class); 278 | $adapter->copy('not_existing_file', 'destination', new Config()); 279 | }); 280 | } 281 | 282 | /** 283 | * We overwrite the test, because the original tries accessing the url 284 | * 285 | * @test 286 | */ 287 | public function generating_a_public_url(): void 288 | { 289 | if (self::$isLive && ! \str_starts_with(static::$publicUrl, self::DEMOURL)) { 290 | parent::generating_a_public_url(); 291 | 292 | return; 293 | } 294 | 295 | $url = $this->adapter()->publicUrl('/path.txt', new Config()); 296 | 297 | self::assertEquals(static::$publicUrl.'/path.txt', $url); 298 | } 299 | 300 | public function test_without_pullzone_url_error_thrown_accessing_url(): void 301 | { 302 | $this->expectException(\RuntimeException::class); 303 | $this->expectExceptionMessage('In order to get a visible URL for a BunnyCDN object, you must pass the "pullzone_url" parameter to the BunnyCDNAdapter.'); 304 | $myAdapter = new BunnyCDNAdapter(static::bunnyCDNClient()); 305 | $myAdapter->publicUrl('/path.txt', new Config()); 306 | } 307 | 308 | /** 309 | * @test 310 | */ 311 | public function overwriting_a_file(): void 312 | { 313 | $this->runScenario(function () { 314 | $this->givenWeHaveAnExistingFile('path.txt', 'contents', ['visibility' => Visibility::PUBLIC]); 315 | $adapter = $this->adapter(); 316 | 317 | $adapter->write('path.txt', 'new contents', new Config(['visibility' => Visibility::PRIVATE])); 318 | 319 | $contents = $adapter->read('path.txt'); 320 | $this->assertEquals('new contents', $contents); 321 | // $visibility = $adapter->visibility('path.txt')->visibility(); 322 | // $this->assertEquals(Visibility::PRIVATE, $visibility); // Commented out of this test 323 | }); 324 | } 325 | 326 | /** 327 | * @test 328 | */ 329 | public function moving_a_file_to_same_destination(): void 330 | { 331 | $this->runScenario(function () { 332 | $adapter = $this->adapter(); 333 | $adapter->write( 334 | 'source.txt', 335 | 'contents to be copied', 336 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 337 | ); 338 | $adapter->move('source.txt', 'source.txt', new Config()); 339 | $this->assertTrue( 340 | $adapter->fileExists('source.txt'), 341 | 'After moving a file to the same location the file should exist.' 342 | ); 343 | }); 344 | } 345 | 346 | /** 347 | * @test 348 | */ 349 | public function get_checksum(): void 350 | { 351 | $adapter = $this->adapter(); 352 | 353 | $adapter->write('path.txt', 'foobar', new Config()); 354 | 355 | $this->assertSame( 356 | '3858f62230ac3c915f300c664312c63f', 357 | $adapter->checksum('path.txt', new Config()) 358 | ); 359 | 360 | $this->assertSame( 361 | 'c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', 362 | $adapter->checksum('path.txt', new Config(['checksum_algo' => 'sha256'])) 363 | ); 364 | } 365 | 366 | public function test_checksum_throws_error_with_non_existing_file_on_default_algo(): void 367 | { 368 | $adapter = $this->adapter(); 369 | 370 | $this->expectException(UnableToProvideChecksum::class); 371 | $adapter->checksum('path.txt', new Config(['checksum_algo' => 'sha256'])); 372 | } 373 | 374 | //test_checksum_throws_error_with_empty_checksum_from_client 375 | public function test_checksum_throws_error_with_empty_checksum_from_client(): void 376 | { 377 | $client = $this->createMock(BunnyCDNClient::class); 378 | $client->expects(self::exactly(1))->method('list')->willReturnCallback( 379 | function () { 380 | ['file' => $file, 'dir' => $dir] = Util::splitPathIntoDirectoryAndFile('file.txt'); 381 | $dir = Util::normalizePath($dir); 382 | $faker = Factory::create(); 383 | $storage_zone = $faker->word; 384 | 385 | return [[ 386 | 'Guid' => $faker->uuid, 387 | 'StorageZoneName' => $storage_zone, 388 | 'Path' => Util::normalizePath('/'.$storage_zone.'/'.$dir.'/'), 389 | 'ObjectName' => $file, 390 | 'Length' => $faker->numberBetween(0, 10240), 391 | 'LastChanged' => date('Y-m-d\TH:i:s.v'), 392 | 'ServerId' => $faker->numberBetween(0, 10240), 393 | 'ArrayNumber' => 0, 394 | 'IsDirectory' => false, 395 | 'UserId' => 'bf91bc4e-0e60-411a-b475-4416926d20f7', 396 | 'ContentType' => '', 397 | 'DateCreated' => date('Y-m-d\TH:i:s.v'), 398 | 'StorageZoneId' => $faker->numberBetween(0, 102400), 399 | 'Checksum' => null, 400 | 'ReplicatedZones' => '', 401 | ]]; 402 | } 403 | ); 404 | 405 | $adapter = new BunnyCDNAdapter($client); 406 | $this->expectException(UnableToProvideChecksum::class); 407 | $this->expectExceptionMessage('Unable to get checksum for file.txt: Checksum not available.'); 408 | $adapter->checksum('file.txt', new Config(['checksum_algo' => 'sha256'])); 409 | } 410 | 411 | /** 412 | * @test 413 | */ 414 | public function fetching_the_mime_type_of_an_svg_file_by_file_name(): void 415 | { 416 | $this->runScenario(function () { 417 | $adapter = $this->adapter(); 418 | $adapter->write( 419 | 'source.svg', 420 | '', 421 | new Config() 422 | ); 423 | 424 | $this->assertSame( 425 | 'image/svg+xml', 426 | $adapter->detectMimeType('source.svg') 427 | ); 428 | }); 429 | } 430 | 431 | /** 432 | * Fix issue where `fopen` complains when opening downloaded image file#20 433 | * https://github.com/PlatformCommunity/flysystem-bunnycdn/pull/20 434 | * 435 | * @return void 436 | * 437 | * @throws FilesystemException 438 | * @throws Throwable 439 | */ 440 | public function test_regression_pr_20() 441 | { 442 | $image = base64_decode('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=='); 443 | $this->givenWeHaveAnExistingFile('path.png', $image); 444 | 445 | $this->runScenario(function () use ($image) { 446 | $adapter = $this->adapter(); 447 | 448 | $stream = $adapter->readStream('path.png'); 449 | 450 | $this->assertIsResource($stream); 451 | $this->assertEquals($image, stream_get_contents($stream)); 452 | }); 453 | } 454 | 455 | /** 456 | * Github Issue - 28 457 | * https://github.com/PlatformCommunity/flysystem-bunnycdn/issues/28 458 | * 459 | * Issue present where a lot of TypeErrors will appear if you ask for lastModified on Directory (returns FileAttributes) 460 | * 461 | * @throws FilesystemException 462 | */ 463 | public function test_regression_issue_29() 464 | { 465 | $client = self::bunnyCDNClient(); 466 | $client->make_directory('/example_folder'); 467 | 468 | $adapter = new Filesystem(new BunnyCDNAdapter($client)); 469 | $exception_count = 0; 470 | 471 | try { 472 | $adapter->fileSize('/example_folder'); 473 | } catch (\Exception $e) { 474 | $this->assertInstanceOf(UnableToRetrieveMetadata::class, $e); 475 | $exception_count++; 476 | } 477 | 478 | try { 479 | $adapter->mimeType('/example_folder'); 480 | } catch (\Exception $e) { 481 | $this->assertInstanceOf(UnableToRetrieveMetadata::class, $e); 482 | $exception_count++; 483 | } 484 | 485 | try { 486 | $adapter->lastModified('/example_folder'); 487 | } catch (\Exception $e) { 488 | $this->assertInstanceOf(UnableToRetrieveMetadata::class, $e); 489 | $exception_count++; 490 | } 491 | 492 | // The fact that PHPUnit makes me do this is 🤬 493 | $this->assertEquals(3, $exception_count); 494 | } 495 | 496 | /** 497 | * Github Issue - 39 498 | * https://github.com/PlatformCommunity/flysystem-bunnycdn/issues/39 499 | * 500 | * Can't request file containing json 501 | * 502 | * @throws FilesystemException 503 | */ 504 | public function test_regression_issue_39() 505 | { 506 | $this->runScenario(function () { 507 | $adapter = $this->adapter(); 508 | 509 | $adapter->write('test.json', json_encode(['test' => 123]), new Config([])); 510 | 511 | $response = $adapter->read('/test.json'); 512 | 513 | $this->assertIsString($response); 514 | }); 515 | } 516 | 517 | public function test_write_batch(): void 518 | { 519 | $this->runScenario(function () { 520 | $firstTmpFile = \tmpfile(); 521 | fwrite($firstTmpFile, 'text'); 522 | $firstTmpPath = stream_get_meta_data($firstTmpFile)['uri']; 523 | 524 | $secondTmpFile = \tmpfile(); 525 | fwrite($secondTmpFile, 'text2'); 526 | $secondTmpPath = stream_get_meta_data($secondTmpFile)['uri']; 527 | 528 | $adapter = $this->adapter(); 529 | 530 | $adapter->writeBatch( 531 | [ 532 | new WriteBatchFile($firstTmpPath, 'destination.txt'), 533 | new WriteBatchFile($secondTmpPath, 'destination2.txt'), 534 | ], 535 | new Config() 536 | ); 537 | 538 | \fclose($firstTmpFile); 539 | \fclose($secondTmpFile); 540 | 541 | $this->assertSame('text', $adapter->read('destination.txt')); 542 | $this->assertSame('text2', $adapter->read('destination2.txt')); 543 | }); 544 | } 545 | 546 | public function test_failing_write_batch(): void 547 | { 548 | if (self::$isLive) { 549 | $this->markTestSkipped('This test is not applicable in live mode'); 550 | } 551 | 552 | $this->runScenario(function () { 553 | $firstTmpFile = \tmpfile(); 554 | fwrite($firstTmpFile, 'text'); 555 | $firstTmpPath = stream_get_meta_data($firstTmpFile)['uri']; 556 | 557 | $secondTmpFile = \tmpfile(); 558 | fwrite($secondTmpFile, 'text2'); 559 | $secondTmpPath = stream_get_meta_data($firstTmpFile)['uri']; 560 | 561 | $adapter = $this->adapter(); 562 | 563 | $this->expectException(UnableToWriteFile::class); 564 | $adapter->writeBatch( 565 | [ 566 | new WriteBatchFile($firstTmpPath, 'failing.txt'), 567 | new WriteBatchFile($secondTmpPath, 'failing2.txt'), 568 | ], 569 | new Config() 570 | ); 571 | 572 | \fclose($firstTmpFile); 573 | \fclose($secondTmpFile); 574 | }); 575 | } 576 | } 577 | -------------------------------------------------------------------------------- /tests/MockClient.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem(new InMemoryFilesystemAdapter()); 31 | } 32 | 33 | /** 34 | * @param string $path 35 | * @return array 36 | */ 37 | public function list(string $path): array 38 | { 39 | try { 40 | return $this->filesystem->listContents($path)->map(function (StorageAttributes $file) { 41 | return ! $file instanceof FileAttributes 42 | ? self::example_folder($file->path(), $this->storage_zone_name, []) 43 | : self::example_file($file->path(), $this->storage_zone_name, [ 44 | 'Length' => $file->fileSize(), 45 | 'Checksum' => hash('sha256', $this->filesystem->read($file->path())), 46 | ]); 47 | })->toArray(); 48 | } catch (FilesystemException) { 49 | } 50 | 51 | return []; 52 | } 53 | 54 | /** 55 | * @param string $path 56 | * @return string 57 | * 58 | * @throws FilesystemException 59 | */ 60 | public function download(string $path): string 61 | { 62 | return $this->filesystem->read($path); 63 | } 64 | 65 | /** 66 | * @param string $path 67 | * @return resource 68 | * 69 | * @throws FilesystemException 70 | */ 71 | public function stream(string $path) 72 | { 73 | return $this->filesystem->readStream($path); 74 | } 75 | 76 | /** 77 | * @param string $path 78 | * @param $contents 79 | * @return array 80 | */ 81 | public function upload(string $path, $contents): array 82 | { 83 | try { 84 | $this->filesystem->write($path, $contents); 85 | 86 | return [ 87 | 'HttpCode' => 201, 88 | 'Message' => 'File uploaded.', 89 | ]; 90 | } catch (FilesystemException) { 91 | } 92 | 93 | return []; 94 | } 95 | 96 | /** 97 | * @param string $path 98 | * @return array 99 | */ 100 | public function make_directory(string $path): array 101 | { 102 | try { 103 | $this->filesystem->createDirectory($path); 104 | 105 | return [ 106 | 'HttpCode' => 201, 107 | 'Message' => 'Directory created.', 108 | ]; 109 | } catch (FilesystemException) { 110 | } 111 | 112 | return []; 113 | } 114 | 115 | /** 116 | * @param string $path 117 | * @return array 118 | * 119 | * @throws FilesystemException 120 | * @throws BunnyCDNException 121 | * @throws NotFoundException 122 | */ 123 | public function delete(string $path): array 124 | { 125 | try { 126 | $this->filesystem->has($path) ? 127 | $this->filesystem->deleteDirectory($path) || $this->filesystem->delete($path) : 128 | throw new NotFoundException(); 129 | 130 | return [ 131 | 'HttpCode' => 200, 132 | 'Message' => 'File deleted successfuly.', // ಠ_ಠ Spelling @bunny.net 133 | ]; 134 | } catch (NotFoundException) { 135 | throw new NotFoundException('404'); 136 | } catch (\Exception) { 137 | return [ 138 | 'HttpCode' => 404, 139 | 'Message' => 'File deleted successfuly.', // ಠ_ಠ Spelling @bunny.net 140 | ]; 141 | } 142 | } 143 | 144 | public function getUploadRequest(string $path, $contents): Request 145 | { 146 | return new Request('PUT', $path, [], $contents); 147 | } 148 | 149 | private static function example_file($path = '/directory/test.png', $storage_zone = 'storage_zone', $override = []): array 150 | { 151 | ['file' => $file, 'dir' => $dir] = Util::splitPathIntoDirectoryAndFile($path); 152 | $dir = Util::normalizePath($dir); 153 | $faker = Factory::create(); 154 | 155 | return array_merge([ 156 | 'Guid' => $faker->uuid, 157 | 'StorageZoneName' => $storage_zone, 158 | 'Path' => Util::normalizePath('/'.$storage_zone.'/'.$dir.'/'), 159 | 'ObjectName' => $file, 160 | 'Length' => $faker->numberBetween(0, 10240), 161 | 'LastChanged' => date('Y-m-d\TH:i:s.v'), 162 | 'ServerId' => $faker->numberBetween(0, 10240), 163 | 'ArrayNumber' => 0, 164 | 'IsDirectory' => false, 165 | 'UserId' => 'bf91bc4e-0e60-411a-b475-4416926d20f7', 166 | 'ContentType' => '', 167 | 'DateCreated' => date('Y-m-d\TH:i:s.v'), 168 | 'StorageZoneId' => $faker->numberBetween(0, 102400), 169 | 'Checksum' => strtoupper($faker->sha256), 170 | 'ReplicatedZones' => '', 171 | ], $override); 172 | } 173 | 174 | private static function example_folder($path = '/directory/', $storage_zone = 'storage_zone', $override = []): array 175 | { 176 | ['file' => $file, 'dir' => $dir] = Util::splitPathIntoDirectoryAndFile($path); 177 | $dir = Util::normalizePath($dir); 178 | $faker = Factory::create(); 179 | 180 | return array_merge([ 181 | 'Guid' => $faker->uuid, 182 | 'StorageZoneName' => $storage_zone, 183 | 'Path' => Util::normalizePath('/'.$storage_zone.'/'.$dir.'/'), 184 | 'ObjectName' => $file, 185 | 'Length' => 0, 186 | 'LastChanged' => date('Y-m-d\TH:i:s.v'), 187 | 'ServerId' => $faker->numberBetween(0, 10240), 188 | 'ArrayNumber' => 0, 189 | 'IsDirectory' => true, 190 | 'UserId' => 'bf91bc4e-0e60-411a-b475-4416926d20f7', 191 | 'ContentType' => '', 192 | 'DateCreated' => date('Y-m-d\TH:i:s.v'), 193 | 'StorageZoneId' => $faker->numberBetween(0, 102400), 194 | 'Checksum' => '', 195 | 'ReplicatedZones' => '', 196 | ], $override); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /tests/PrefixTest.php: -------------------------------------------------------------------------------- 1 | deleteDirectory('/'.self::PREFIX_PATH); 56 | } catch (FilesystemException $e) { 57 | } 58 | } 59 | 60 | /** 61 | * Skipped 62 | */ 63 | public function setting_visibility(): void 64 | { 65 | $this->markTestSkipped('No visibility support is provided for BunnyCDN'); 66 | } 67 | 68 | public function generating_a_temporary_url(): void 69 | { 70 | $this->markTestSkipped('No temporary URL support is provided for BunnyCDN'); 71 | } 72 | 73 | /** 74 | * Overwritten (usually because of visibility) 75 | */ 76 | 77 | /** 78 | * We overwrite the test, because the original tries accessing the url 79 | * 80 | * @test 81 | */ 82 | public function generating_a_public_url(): void 83 | { 84 | $url = $this->adapter()->publicUrl('path.txt', new Config()); 85 | 86 | self::assertEquals('https://example.org.local/assets/path_prefix_12345/path.txt', $url); 87 | } 88 | 89 | public function overwriting_a_file(): void 90 | { 91 | $this->runScenario(function () { 92 | $this->givenWeHaveAnExistingFile('path.txt', 'contents', ['visibility' => Visibility::PUBLIC]); 93 | $adapter = $this->adapter(); 94 | 95 | $adapter->write('path.txt', 'new contents', new Config(['visibility' => Visibility::PRIVATE])); 96 | 97 | $contents = $adapter->read('path.txt'); 98 | $this->assertEquals('new contents', $contents); 99 | // $visibility = $adapter->visibility('path.txt')->visibility(); 100 | // $this->assertEquals(Visibility::PRIVATE, $visibility); // Commented out of this test 101 | }); 102 | } 103 | 104 | /** 105 | * @test 106 | */ 107 | public function get_checksum(): void 108 | { 109 | $adapter = $this->adapter(); 110 | 111 | if (! $adapter instanceof ChecksumProvider) { 112 | $this->markTestSkipped('Adapter does not supply providing checksums'); 113 | } 114 | 115 | $adapter->write('path.txt', 'foobar', new Config()); 116 | 117 | $this->assertSame( 118 | '3858f62230ac3c915f300c664312c63f', 119 | $adapter->checksum('path.txt', new Config()) 120 | ); 121 | 122 | $this->assertSame( 123 | 'c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', 124 | $adapter->checksum('path.txt', new Config(['checksum_algo' => 'sha256'])) 125 | ); 126 | } 127 | 128 | public function test_construct_throws_error(): void 129 | { 130 | $this->expectException(\RuntimeException::class); 131 | $this->expectExceptionMessage('PrefixPath is no longer supported directly. Use PathPrefixedAdapter instead: https://flysystem.thephpleague.com/docs/adapter/path-prefixing/'); 132 | new BunnyCDNAdapter(self::bunnyCDNClient(), 'https://example.org.local/assets/', 'thisisauselessarg'); 133 | } 134 | 135 | /** 136 | * @test 137 | */ 138 | public function prefix_path(): void 139 | { 140 | $this->runScenario(function () { 141 | $regularAdapter = self::bunnyCDNAdapter(); 142 | $prefixPathAdapter = new PathPrefixedAdapter($regularAdapter, self::PREFIX_PATH); 143 | 144 | self::assertNotEmpty( 145 | self::PREFIX_PATH 146 | ); 147 | 148 | self::assertIsString( 149 | self::PREFIX_PATH 150 | ); 151 | 152 | $content = 'this is test'; 153 | $prefixPathAdapter->write( 154 | 'source.file.svg', 155 | $content, 156 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 157 | ); 158 | 159 | self::assertTrue($prefixPathAdapter->fileExists( 160 | 'source.file.svg' 161 | )); 162 | 163 | self::assertTrue($regularAdapter->directoryExists( 164 | self::PREFIX_PATH 165 | )); 166 | 167 | self::assertTrue($regularAdapter->fileExists( 168 | self::PREFIX_PATH.'/source.file.svg' 169 | )); 170 | 171 | $prefixPathAdapter->copy( 172 | 'source.file.svg', 173 | 'source.copy.file.svg', 174 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 175 | ); 176 | 177 | self::assertTrue($regularAdapter->fileExists( 178 | self::PREFIX_PATH.'/source.copy.file.svg' 179 | )); 180 | 181 | self::assertTrue($prefixPathAdapter->fileExists( 182 | 'source.copy.file.svg' 183 | )); 184 | 185 | $prefixPathAdapter->delete( 186 | 'source.copy.file.svg' 187 | ); 188 | 189 | $this->assertEquals($content, $prefixPathAdapter->read('source.file.svg')); 190 | 191 | $this->assertEquals( 192 | $prefixPathAdapter->read('source.file.svg'), 193 | $regularAdapter->read(self::PREFIX_PATH.'/source.file.svg') 194 | ); 195 | 196 | $this->assertEquals($content, stream_get_contents($prefixPathAdapter->readStream('source.file.svg'))); 197 | 198 | $this->assertEquals( 199 | stream_get_contents($prefixPathAdapter->readStream('source.file.svg')), 200 | stream_get_contents($regularAdapter->readStream(self::PREFIX_PATH.'/source.file.svg')) 201 | ); 202 | 203 | $this->assertSame( 204 | 'image/svg+xml', 205 | $prefixPathAdapter->mimeType('source.file.svg')->mimeType() 206 | ); 207 | 208 | $this->assertEquals( 209 | $prefixPathAdapter->mimeType('source.file.svg')->mimeType(), 210 | $regularAdapter->mimeType(self::PREFIX_PATH.'/source.file.svg')->mimeType() 211 | ); 212 | 213 | $this->assertGreaterThan( 214 | 0, 215 | $prefixPathAdapter->fileSize('source.file.svg')->fileSize() 216 | ); 217 | 218 | $this->assertEquals( 219 | $prefixPathAdapter->fileSize('source.file.svg')->fileSize(), 220 | $regularAdapter->fileSize(self::PREFIX_PATH.'/source.file.svg')->fileSize() 221 | ); 222 | 223 | $this->assertGreaterThan( 224 | time() - 30, 225 | $prefixPathAdapter->lastModified('source.file.svg')->lastModified() 226 | ); 227 | 228 | $this->assertEquals( 229 | $prefixPathAdapter->lastModified('source.file.svg')->lastModified(), 230 | $regularAdapter->lastModified(self::PREFIX_PATH.'/source.file.svg')->lastModified() 231 | ); 232 | 233 | $prefixPathAdapter->delete( 234 | 'source.file.svg' 235 | ); 236 | 237 | self::assertFalse($prefixPathAdapter->fileExists( 238 | 'source.file.svg' 239 | )); 240 | 241 | $prefixPathAdapter->write( 242 | 'subfolder/subfolder2/source.file.svg', 243 | $content, 244 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 245 | ); 246 | 247 | self::assertTrue($regularAdapter->fileExists( 248 | self::PREFIX_PATH.'/subfolder/subfolder2/source.file.svg' 249 | )); 250 | 251 | $prefixPathAdapter->move( 252 | 'subfolder', 253 | 'newsubfolder', 254 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 255 | ); 256 | 257 | self::assertFalse($regularAdapter->fileExists( 258 | self::PREFIX_PATH.'/subfolder/subfolder2/source.file.svg' 259 | )); 260 | 261 | self::assertTrue($prefixPathAdapter->fileExists( 262 | 'newsubfolder/subfolder2/source.file.svg' 263 | )); 264 | }); 265 | } 266 | 267 | /** 268 | * @test 269 | * 270 | * @throws FilesystemException 271 | * @throws \Throwable 272 | */ 273 | public function prefix_path_not_in_meta_pr_36(): void 274 | { 275 | $this->dontRetryOnException(); 276 | 277 | $this->runScenario(function () { 278 | $prefixPathAdapter = $this->adapter(); 279 | 280 | $prefixPathAdapter->write( 281 | 'source.file.svg', 282 | '----', 283 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 284 | ); 285 | 286 | $contents = \iterator_to_array($prefixPathAdapter->listContents('/', false)); 287 | 288 | $this->assertCount(1, $contents); 289 | $this->assertSame('source.file.svg', $contents[0]['path']); 290 | 291 | $prefixPathAdapter->delete('source.file.svg'); 292 | }); 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /tests/UtilityClassTest.php: -------------------------------------------------------------------------------- 1 | assertTrue( 19 | Util::startsWith('/test', '/') 20 | ); 21 | 22 | $this->assertFalse( 23 | Util::startsWith('test', '/') 24 | ); 25 | } 26 | 27 | /** 28 | * @test 29 | * 30 | * @throws Exception 31 | */ 32 | public function it_ends_with() 33 | { 34 | $this->assertTrue( 35 | Util::endsWith('test/', '/') 36 | ); 37 | 38 | $this->assertFalse( 39 | Util::endsWith('test', '/') 40 | ); 41 | 42 | $this->assertTrue( 43 | Util::endsWith('test', '') 44 | ); 45 | } 46 | 47 | /** 48 | * @test 49 | * 50 | * @throws Exception 51 | */ 52 | public function it_tests_normalize_path() 53 | { 54 | $this->assertEquals( 55 | 'test/', 56 | Util::normalizePath('/test/', true) 57 | ); 58 | 59 | $this->assertEquals( 60 | 'test/', 61 | Util::normalizePath('/test', true) 62 | ); 63 | 64 | $this->assertEquals( 65 | 'test', 66 | Util::normalizePath('/test', false) 67 | ); 68 | } 69 | 70 | /** 71 | * @test 72 | * 73 | * @throws Exception 74 | */ 75 | public function it_path_split() 76 | { 77 | $this->assertEquals( 78 | [ 79 | 'file' => 'testing-dir', 80 | 'dir' => '', 81 | ], 82 | Util::splitPathIntoDirectoryAndFile('/testing-dir') 83 | ); 84 | 85 | $this->assertEquals( 86 | [ 87 | 'file' => 'testing.txt', 88 | 'dir' => '', 89 | ], 90 | Util::splitPathIntoDirectoryAndFile('/testing.txt') 91 | ); 92 | 93 | $this->assertEquals( 94 | [ 95 | 'file' => 'testing-dir', 96 | 'dir' => '', 97 | ], 98 | Util::splitPathIntoDirectoryAndFile('/testing-dir/') 99 | ); 100 | 101 | $this->assertEquals( 102 | [ 103 | 'file' => 'file.txt', 104 | 'dir' => '/testing-dir', 105 | ], 106 | Util::splitPathIntoDirectoryAndFile('/testing-dir/file.txt') 107 | ); 108 | 109 | $this->assertEquals( 110 | [ 111 | 'file' => 'file.txt', 112 | 'dir' => '/testing-dir/nested', 113 | ], 114 | Util::splitPathIntoDirectoryAndFile('/testing-dir/nested/file.txt') 115 | ); 116 | } 117 | 118 | public function test_replace_first() 119 | { 120 | $this->assertSame( 121 | 'SX', 122 | Util::replaceFirst('X', 'S', 'XX') 123 | ); 124 | 125 | $this->assertSame( 126 | 'ORIGINAL', 127 | Util::replaceFirst('X', 'S', 'ORIGINAL') 128 | ); 129 | } 130 | } 131 | --------------------------------------------------------------------------------