├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── README.md ├── composer.json └── src └── CosAdapter.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false 10 | 11 | [*.{vue,js,scss}] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 2 15 | end_of_line = lf 16 | insert_final_newline = true 17 | trim_trailing_whitespace = true 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [overtrue] 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | phpunit: 6 | name: PHP-${{ matrix.php_version }}-${{ matrix.perfer }} 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | php_version: 12 | - 8.0 13 | - 8.1 14 | - 8.2 15 | perfer: 16 | - stable 17 | steps: 18 | - uses: actions/checkout@master 19 | - name: Install Dependencies 20 | run: composer update --prefer-dist --no-interaction --no-suggest --prefer-${{ matrix.perfer }} 21 | - name: Run PHPUnit 22 | run: ./vendor/bin/phpunit 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flysystem QCloud COS 2 | 3 | --- 4 | 5 | :floppy_disk: Flysystem adapter for the Qcloud COS storage. 6 | 7 | [![Test](https://github.com/overtrue/flysystem-cos/actions/workflows/test.yml/badge.svg)](https://github.com/overtrue/flysystem-cos/actions/workflows/test.yml) [![Latest Stable Version](https://poser.pugx.org/overtrue/flysystem-cos/v/stable.svg)](https://packagist.org/packages/overtrue/flysystem-cos) [![Latest Unstable Version](https://poser.pugx.org/overtrue/flysystem-cos/v/unstable.svg)](https://packagist.org/packages/overtrue/flysystem-cos) [![Total Downloads](https://poser.pugx.org/overtrue/flysystem-cos/downloads)](https://packagist.org/packages/overtrue/flysystem-cos) [![License](https://poser.pugx.org/overtrue/flysystem-cos/license)](https://packagist.org/packages/overtrue/flysystem-cos) 8 | 9 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me-button-s.svg?raw=true)](https://github.com/sponsors/overtrue) 10 | 11 | ## Requirement 12 | 13 | * PHP >= 8.0.2 14 | 15 | ## Installation 16 | 17 | ```shell 18 | composer require overtrue/flysystem-cos -vvv 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```php 24 | use League\Flysystem\Filesystem; 25 | use Overtrue\Flysystem\Cos\CosAdapter; 26 | 27 | $config = [ 28 | // 必填,app_id、secret_id、secret_key 29 | // 可在个人秘钥管理页查看:https://console.cloud.tencent.com/capi 30 | 'app_id' => 10020201024, 31 | 'secret_id' => 'AKIDsiQzQla780mQxLLU2GJCxxxxxxxxxxx', 32 | 'secret_key' => 'b0GMH2c2NXWKxPhy77xhHgwxxxxxxxxxxx', 33 | 34 | 'region' => 'ap-guangzhou', 35 | 'bucket' => 'example', 36 | 37 | // 可选,如果 bucket 为私有访问请打开此项 38 | 'signed_url' => false, 39 | 40 | // 可选,是否使用 https,默认 false 41 | 'use_https' => true, 42 | 43 | // 可选,自定义域名 44 | 'domain' => 'emample-12340000.cos.test.com', 45 | 46 | // 可选,使用 CDN 域名时指定生成的 URL host 47 | 'cdn' => 'https://youcdn.domain.com/', 48 | ]; 49 | 50 | $adapter = new CosAdapter($config); 51 | 52 | $flysystem = new League\Flysystem\Filesystem($adapter); 53 | 54 | ``` 55 | ## API 56 | 57 | ```php 58 | 59 | bool $flysystem->write('file.md', 'contents'); 60 | 61 | bool $flysystem->write('file.md', 'http://httpbin.org/robots.txt', ['mime' => 'application/redirect302']); 62 | 63 | bool $flysystem->writeStream('file.md', fopen('path/to/your/local/file.jpg', 'r')); 64 | 65 | bool $flysystem->move('foo.md', 'bar.md'); 66 | 67 | bool $flysystem->copy('foo.md', 'foo2.md'); 68 | 69 | bool $flysystem->delete('file.md'); 70 | 71 | bool $flysystem->fileExists('file.md'); 72 | 73 | string|mixed|false $flysystem->read('file.md'); 74 | 75 | array $flysystem->listContents(); 76 | 77 | int $flysystem->fileSize('file.md'); 78 | 79 | string $flysystem->mimeType('file.md'); 80 | 81 | int $flysystem->lastModified('file.md'); 82 | 83 | ``` 84 | 85 | ## :heart: Sponsor me 86 | 87 | [![Sponsor me](https://github.com/overtrue/overtrue/blob/master/sponsor-me.svg?raw=true)](https://github.com/sponsors/overtrue) 88 | 89 | 如果你喜欢我的项目并想支持它,[点击这里 :heart:](https://github.com/sponsors/overtrue) 90 | 91 | ## Project supported by JetBrains 92 | 93 | Many thanks to Jetbrains for kindly providing a license for me to work on this and other open-source projects. 94 | 95 | [![](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://www.jetbrains.com/?from=https://github.com/overtrue) 96 | 97 | 98 | ## License 99 | 100 | MIT 101 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overtrue/flysystem-cos", 3 | "description": "Flysystem adapter for the QCloud COS storage.", 4 | "require": { 5 | "php": ">=8.0.2", 6 | "league/flysystem": "^3.0", 7 | "overtrue/qcloud-cos-client": "^2.1.4" 8 | }, 9 | "require-dev": { 10 | "phpunit/phpunit": "^9.5", 11 | "mockery/mockery": "^1.0", 12 | "league/flysystem-adapter-test-utilities": "^3.0", 13 | "laravel/pint": "^1.6" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Overtrue\\Flysystem\\Cos\\": "src" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Overtrue\\Flysystem\\Cos\\Tests\\": "tests" 23 | } 24 | }, 25 | "authors": [ 26 | { 27 | "name": "overtrue", 28 | "email": "i@overtrue.me" 29 | } 30 | ], 31 | "license": "MIT", 32 | "scripts": { 33 | "post-merge": "composer install", 34 | "check-style": "vendor/bin/pint --test", 35 | "fix-style": "vendor/bin/pint", 36 | "test": "phpunit --colors=always" 37 | }, 38 | "scripts-descriptions": { 39 | "test": "Run all tests.", 40 | "check-style": "Run style checks (only dry run - no fixing!).", 41 | "fix-style": "Run style checks and fix violations." 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/CosAdapter.php: -------------------------------------------------------------------------------- 1 | config = \array_merge( 41 | [ 42 | 'bucket' => null, 43 | 'app_id' => null, 44 | 'region' => 'ap-guangzhou', 45 | 'signed_url' => false, 46 | ], 47 | $config 48 | ); 49 | 50 | $this->prefixer = new PathPrefixer($config['prefix'] ?? ''); 51 | } 52 | 53 | /** 54 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 55 | * @throws \Throwable 56 | */ 57 | public function fileExists(string $path): bool 58 | { 59 | return $this->getMetadata($path) !== false; 60 | } 61 | 62 | /** 63 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 64 | */ 65 | public function directoryExists(string $path): bool 66 | { 67 | return $this->fileExists($path); 68 | } 69 | 70 | /** 71 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 72 | */ 73 | public function write(string $path, string $contents, Config $config): void 74 | { 75 | $prefixedPath = $this->prefixer->prefixPath($path); 76 | $response = $this->getObjectClient()->putObject($prefixedPath, $contents, $config->get('headers', [])); 77 | 78 | if (! $response->isSuccessful()) { 79 | throw UnableToWriteFile::atLocation($path, (string) $response->getBody()); 80 | } 81 | 82 | if ($visibility = $config->get('visibility')) { 83 | $this->setVisibility($path, $visibility); 84 | } 85 | } 86 | 87 | /** 88 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 89 | */ 90 | public function writeStream(string $path, $contents, Config $config): void 91 | { 92 | $this->write($path, \stream_get_contents($contents), $config); 93 | } 94 | 95 | /** 96 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 97 | */ 98 | public function read(string $path): string 99 | { 100 | $prefixedPath = $this->prefixer->prefixPath($path); 101 | 102 | $response = $this->getObjectClient()->getObject($prefixedPath); 103 | if ($response->isNotFound()) { 104 | throw UnableToReadFile::fromLocation($path, (string) $response->getBody()); 105 | } 106 | 107 | return (string) $response->getBody(); 108 | } 109 | 110 | /** 111 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 112 | */ 113 | public function readStream(string $path) 114 | { 115 | $prefixedPath = $this->prefixer->prefixPath($path); 116 | 117 | $response = $this->getObjectClient()->get(\urlencode($prefixedPath), ['stream' => true]); 118 | 119 | if ($response->isNotFound()) { 120 | return false; 121 | } 122 | 123 | return $response->getBody()->detach(); 124 | } 125 | 126 | /** 127 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 128 | */ 129 | public function delete(string $path): void 130 | { 131 | $prefixedPath = $this->prefixer->prefixPath($path); 132 | 133 | $response = $this->getObjectClient()->deleteObject($prefixedPath); 134 | 135 | if (! $response->isSuccessful()) { 136 | throw UnableToDeleteFile::atLocation($path, (string) $response->getBody()); 137 | } 138 | } 139 | 140 | /** 141 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 142 | */ 143 | public function deleteDirectory(string $path): void 144 | { 145 | $dirname = $this->prefixer->prefixPath($path); 146 | 147 | $response = $this->listObjects($dirname); 148 | 149 | if (empty($response['Contents'])) { 150 | return; 151 | } 152 | 153 | $keys = array_map( 154 | function ($item) { 155 | return ['Key' => $item['Key']]; 156 | }, 157 | $response['Contents'] 158 | ); 159 | 160 | $response = $this->getObjectClient()->deleteObjects( 161 | [ 162 | 'Quiet' => 'false', 163 | 'Object' => Transformer::wrap($keys, true, 'Object'), 164 | ] 165 | ); 166 | 167 | if (! $response->isSuccessful()) { 168 | throw UnableToDeleteDirectory::atLocation($path, (string) $response->getBody()); 169 | } 170 | } 171 | 172 | /** 173 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 174 | */ 175 | public function createDirectory(string $path, Config $config): void 176 | { 177 | $dirname = $this->prefixer->prefixPath($path); 178 | 179 | $this->getObjectClient()->putObject($dirname.'/', ''); 180 | } 181 | 182 | /** 183 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 184 | */ 185 | public function setVisibility(string $path, string $visibility): void 186 | { 187 | $this->getObjectClient()->putObjectACL( 188 | $this->prefixer->prefixPath($path), 189 | [], 190 | [ 191 | 'x-cos-acl' => $this->normalizeVisibility($visibility), 192 | ] 193 | ); 194 | } 195 | 196 | /** 197 | * @see https://cloud.tencent.com/document/product/436/7744 198 | * 199 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 200 | */ 201 | public function visibility(string $path): FileAttributes 202 | { 203 | $prefixedPath = $this->prefixer->prefixPath($path); 204 | 205 | $meta = $this->getObjectClient()->getObjectACL($prefixedPath); 206 | 207 | $grants = $meta['AccessControlList']['Grant'] ?? []; 208 | 209 | if (array_key_exists('Grantee', $grants)) { 210 | $grants = [$grants]; 211 | } 212 | 213 | foreach ($grants as $grant) { 214 | // 215 | // 216 | // http://cam.qcloud.com/groups/global/AllUsers 217 | // 218 | // READ 219 | // 220 | $allowRead = $grant['Permission'] === 'READ' || $grant['Permission'] === 'FULL_CONTROL'; 221 | 222 | if ($allowRead && str_contains($grant['Grantee']['URI'] ?? '', 'global/AllUsers')) { 223 | return new FileAttributes($path, null, Visibility::PUBLIC); 224 | } 225 | } 226 | 227 | return new FileAttributes($path, null, Visibility::PRIVATE); 228 | } 229 | 230 | /** 231 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 232 | * @throws \Throwable 233 | */ 234 | public function mimeType(string $path): FileAttributes 235 | { 236 | $meta = $this->getMetadata($path); 237 | if (! $meta || $meta->mimeType() === null) { 238 | throw UnableToRetrieveMetadata::mimeType($path); 239 | } 240 | 241 | return $meta; 242 | } 243 | 244 | /** 245 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 246 | * @throws \Throwable 247 | */ 248 | public function lastModified(string $path): FileAttributes 249 | { 250 | $meta = $this->getMetadata($path); 251 | 252 | if (! $meta || $meta->lastModified() === null) { 253 | throw UnableToRetrieveMetadata::lastModified($path); 254 | } 255 | 256 | return $meta; 257 | } 258 | 259 | /** 260 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 261 | * @throws \Throwable 262 | */ 263 | public function fileSize(string $path): FileAttributes 264 | { 265 | $meta = $this->getMetadata($path); 266 | 267 | if (! $meta || $meta->fileSize() === null) { 268 | throw UnableToRetrieveMetadata::fileSize($path); 269 | } 270 | 271 | return $meta; 272 | } 273 | 274 | public function listContents(string $path, bool $deep): iterable 275 | { 276 | $prefixedPath = $this->prefixer->prefixPath($path); 277 | 278 | $response = $this->listObjects($prefixedPath, $deep); 279 | 280 | // 处理目录 281 | foreach ($response['CommonPrefixes'] ?? [] as $prefix) { 282 | yield new DirectoryAttributes($prefix['Prefix']); 283 | } 284 | 285 | foreach ($response['Contents'] ?? [] as $content) { 286 | yield new FileAttributes( 287 | $content['Key'], 288 | \intval($content['Size']), 289 | null, 290 | \strtotime($content['LastModified']) 291 | ); 292 | } 293 | } 294 | 295 | /** 296 | * @throws \Overtrue\CosClient\Exceptions\InvalidArgumentException 297 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 298 | */ 299 | public function move(string $source, string $destination, Config $config): void 300 | { 301 | $this->copy($source, $destination, $config); 302 | 303 | $this->delete($this->prefixer->prefixPath($source)); 304 | } 305 | 306 | /** 307 | * @throws \Overtrue\CosClient\Exceptions\InvalidArgumentException 308 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 309 | */ 310 | public function copy(string $source, string $destination, Config $config): void 311 | { 312 | $prefixedSource = $this->prefixer->prefixPath($source); 313 | 314 | $location = $this->getSourcePath($prefixedSource); 315 | 316 | $prefixedDestination = $this->prefixer->prefixPath($destination); 317 | 318 | $response = $this->getObjectClient()->copyObject( 319 | $prefixedDestination, 320 | [ 321 | 'x-cos-copy-source' => $location, 322 | ] 323 | ); 324 | if (! $response->isSuccessful()) { 325 | throw UnableToCopyFile::fromLocationTo($source, $destination); 326 | } 327 | } 328 | 329 | /** 330 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 331 | */ 332 | public function getUrl(string $path): string 333 | { 334 | $prefixedPath = $this->prefixer->prefixPath($path); 335 | 336 | if (! empty($this->config['cdn'])) { 337 | return \strval(new Uri(\sprintf('%s/%s', \rtrim($this->config['cdn'], '/'), $prefixedPath))); 338 | } 339 | 340 | return $this->config['signed_url'] ? $this->getSignedUrl($path) : $this->getObjectClient()->getObjectUrl($prefixedPath); 341 | } 342 | 343 | public function getTemporaryUrl(string $path, $expiration) 344 | { 345 | if ($expiration instanceof \DateTimeInterface) { 346 | $expiration = $expiration->getTimestamp(); 347 | } 348 | 349 | try { 350 | return $this->getSignedUrl($path, $expiration); 351 | } catch (\Throwable $exception) { 352 | throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); 353 | } 354 | } 355 | 356 | public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string 357 | { 358 | if ($expiresAt instanceof \DateTimeInterface) { 359 | $expiration = $expiresAt->getTimestamp(); 360 | } 361 | 362 | try { 363 | return $this->getSignedUrl($path, $expiration); 364 | } catch (\Throwable $exception) { 365 | throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); 366 | } 367 | } 368 | 369 | /** 370 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 371 | */ 372 | public function getSignedUrl(string $path, int|string $expires = '+60 minutes'): string 373 | { 374 | $prefixedPath = $this->prefixer->prefixPath($path); 375 | 376 | return $this->getObjectClient()->getObjectSignedUrl($prefixedPath, $expires); 377 | } 378 | 379 | /** 380 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 381 | */ 382 | public function getObjectClient(): ObjectClient 383 | { 384 | return $this->objectClient ?? $this->objectClient = new ObjectClient($this->config); 385 | } 386 | 387 | /** 388 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 389 | */ 390 | public function getBucketClient(): BucketClient 391 | { 392 | return $this->bucketClient ?? $this->bucketClient = new BucketClient($this->config); 393 | } 394 | 395 | public function setObjectClient(ObjectClient $objectClient): CosAdapter 396 | { 397 | $this->objectClient = $objectClient; 398 | 399 | return $this; 400 | } 401 | 402 | public function setBucketClient(BucketClient $bucketClient): CosAdapter 403 | { 404 | $this->bucketClient = $bucketClient; 405 | 406 | return $this; 407 | } 408 | 409 | protected function getSourcePath(string $path): string 410 | { 411 | return sprintf( 412 | '%s-%s.cos.%s.myqcloud.com/%s', 413 | $this->config['bucket'], 414 | $this->config['app_id'], 415 | $this->config['region'], 416 | $path 417 | ); 418 | } 419 | 420 | /** 421 | * @throws \Overtrue\CosClient\Exceptions\InvalidConfigException 422 | * @throws \Throwable 423 | */ 424 | protected function getMetadata($path): bool|FileAttributes 425 | { 426 | try { 427 | $prefixedPath = $this->prefixer->prefixPath($path); 428 | 429 | $meta = $this->getObjectClient()->headObject($prefixedPath)->getHeaders(); 430 | if (empty($meta)) { 431 | return false; 432 | } 433 | } catch (\Throwable $e) { 434 | if ($e instanceof ClientException && $e->getCode() === 404) { 435 | return false; 436 | } 437 | 438 | throw $e; 439 | } 440 | 441 | return new FileAttributes( 442 | $path, 443 | isset($meta['Content-Length'][0]) ? \intval($meta['Content-Length'][0]) : null, 444 | null, 445 | isset($meta['Last-Modified'][0]) ? \strtotime($meta['Last-Modified'][0]) : null, 446 | $meta['Content-Type'][0] ?? null, 447 | ); 448 | } 449 | 450 | protected function listObjects(string $directory = '', bool $recursive = false) 451 | { 452 | $result = $this->getBucketClient()->getObjects( 453 | [ 454 | 'prefix' => empty($directory) ? '' : ($directory.'/'), 455 | 'delimiter' => $recursive ? '' : '/', 456 | ] 457 | )->toArray(); 458 | 459 | foreach (['CommonPrefixes', 'Contents'] as $key) { 460 | $result[$key] = $result[$key] ?? []; 461 | 462 | // 确保是二维数组 463 | if (($index = \key($result[$key])) !== 0) { 464 | $result[$key] = \is_null($index) ? [] : [$result[$key]]; 465 | } 466 | 467 | //过滤掉目录 468 | if ($key === 'Contents') { 469 | $result[$key] = \array_filter($result[$key], function ($item) { 470 | return ! \str_ends_with($item['Key'], '/'); 471 | }); 472 | } 473 | } 474 | 475 | return $result; 476 | } 477 | 478 | protected function normalizeVisibility(string $visibility): string 479 | { 480 | return match ($visibility) { 481 | Visibility::PUBLIC => 'public-read', 482 | Visibility::PRIVATE => 'private', 483 | default => 'default', 484 | }; 485 | } 486 | } 487 | --------------------------------------------------------------------------------