├── AsyncAwsS3Adapter.php ├── LICENSE ├── PortableVisibilityConverter.php ├── VisibilityConverter.php └── composer.json /AsyncAwsS3Adapter.php: -------------------------------------------------------------------------------- 1 | prefixer = new PathPrefixer($prefix); 122 | $this->visibility = $visibility ?? new PortableVisibilityConverter(); 123 | $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); 124 | $this->forwardedOptions = $forwardedOptions; 125 | $this->metadataFields = $metadataFields; 126 | } 127 | 128 | public function fileExists(string $path): bool 129 | { 130 | try { 131 | return $this->client->objectExists( 132 | [ 133 | 'Bucket' => $this->bucket, 134 | 'Key' => $this->prefixer->prefixPath($path), 135 | ] 136 | )->isSuccess(); 137 | } catch (ClientException $e) { 138 | throw UnableToCheckFileExistence::forLocation($path, $e); 139 | } 140 | } 141 | 142 | public function write(string $path, string $contents, Config $config): void 143 | { 144 | $this->upload($path, $contents, $config); 145 | } 146 | 147 | public function writeStream(string $path, $contents, Config $config): void 148 | { 149 | $this->upload($path, $contents, $config); 150 | } 151 | 152 | public function read(string $path): string 153 | { 154 | $body = $this->readObject($path); 155 | 156 | return $body->getContentAsString(); 157 | } 158 | 159 | public function readStream(string $path) 160 | { 161 | $body = $this->readObject($path); 162 | 163 | return $body->getContentAsResource(); 164 | } 165 | 166 | public function delete(string $path): void 167 | { 168 | $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 169 | 170 | try { 171 | $this->client->deleteObject($arguments); 172 | } catch (Throwable $exception) { 173 | throw UnableToDeleteFile::atLocation($path, '', $exception); 174 | } 175 | } 176 | 177 | public function deleteDirectory(string $path): void 178 | { 179 | $prefix = $this->prefixer->prefixDirectoryPath($path); 180 | $prefix = ltrim($prefix, '/'); 181 | 182 | $objects = []; 183 | $params = ['Bucket' => $this->bucket, 'Prefix' => $prefix]; 184 | 185 | try { 186 | $result = $this->client->listObjectsV2($params); 187 | /** @var AwsObject $item */ 188 | foreach ($result->getContents() as $item) { 189 | $key = $item->getKey(); 190 | if (null !== $key) { 191 | $objects[] = new ObjectIdentifier(['Key' => $key]); 192 | } 193 | } 194 | 195 | if (empty($objects)) { 196 | return; 197 | } 198 | 199 | foreach (array_chunk($objects, 1000) as $chunk) { 200 | $this->client->deleteObjects([ 201 | 'Bucket' => $this->bucket, 202 | 'Delete' => ['Objects' => $chunk], 203 | ]); 204 | } 205 | } catch (\Throwable $e) { 206 | throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e); 207 | } 208 | } 209 | 210 | public function createDirectory(string $path, Config $config): void 211 | { 212 | $defaultVisibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $this->visibility->defaultForDirectories()); 213 | $config = $config->withDefaults([Config::OPTION_VISIBILITY => $defaultVisibility]); 214 | 215 | try { 216 | $this->upload(rtrim($path, '/') . '/', '', $config); 217 | } catch (Throwable $e) { 218 | throw UnableToCreateDirectory::dueToFailure($path, $e); 219 | } 220 | } 221 | 222 | public function setVisibility(string $path, string $visibility): void 223 | { 224 | $arguments = [ 225 | 'Bucket' => $this->bucket, 226 | 'Key' => $this->prefixer->prefixPath($path), 227 | 'ACL' => $this->visibility->visibilityToAcl($visibility), 228 | ]; 229 | 230 | try { 231 | $this->client->putObjectAcl($arguments); 232 | } catch (Throwable $exception) { 233 | throw UnableToSetVisibility::atLocation($path, $exception->getMessage(), $exception); 234 | } 235 | } 236 | 237 | public function visibility(string $path): FileAttributes 238 | { 239 | $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 240 | 241 | try { 242 | $result = $this->client->getObjectAcl($arguments); 243 | $grants = $result->getGrants(); 244 | } catch (Throwable $exception) { 245 | throw UnableToRetrieveMetadata::visibility($path, $exception->getMessage(), $exception); 246 | } 247 | 248 | $visibility = $this->visibility->aclToVisibility($grants); 249 | 250 | return new FileAttributes($path, null, $visibility); 251 | } 252 | 253 | public function mimeType(string $path): FileAttributes 254 | { 255 | $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE); 256 | 257 | if (null === $attributes->mimeType()) { 258 | throw UnableToRetrieveMetadata::mimeType($path); 259 | } 260 | 261 | return $attributes; 262 | } 263 | 264 | public function lastModified(string $path): FileAttributes 265 | { 266 | $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED); 267 | 268 | if (null === $attributes->lastModified()) { 269 | throw UnableToRetrieveMetadata::lastModified($path); 270 | } 271 | 272 | return $attributes; 273 | } 274 | 275 | public function fileSize(string $path): FileAttributes 276 | { 277 | $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); 278 | 279 | if (null === $attributes->fileSize()) { 280 | throw UnableToRetrieveMetadata::fileSize($path); 281 | } 282 | 283 | return $attributes; 284 | } 285 | 286 | public function directoryExists(string $path): bool 287 | { 288 | try { 289 | $prefix = $this->prefixer->prefixDirectoryPath($path); 290 | $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix, 'MaxKeys' => 1, 'Delimiter' => '/']; 291 | 292 | return $this->client->listObjectsV2($options)->getKeyCount() > 0; 293 | } catch (Throwable $exception) { 294 | throw UnableToCheckDirectoryExistence::forLocation($path, $exception); 295 | } 296 | } 297 | 298 | public function listContents(string $path, bool $deep): iterable 299 | { 300 | $path = trim($path, '/'); 301 | $prefix = trim($this->prefixer->prefixPath($path), '/'); 302 | $prefix = empty($prefix) ? '' : $prefix . '/'; 303 | $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix]; 304 | 305 | if (false === $deep) { 306 | $options['Delimiter'] = '/'; 307 | } 308 | 309 | try { 310 | $listing = $this->retrievePaginatedListing($options); 311 | 312 | foreach ($listing as $item) { 313 | $item = $this->mapS3ObjectMetadata($item); 314 | 315 | if ($item->path() === $path) { 316 | continue; 317 | } 318 | 319 | yield $item; 320 | } 321 | } catch (\Throwable $e) { 322 | throw UnableToListContents::atLocation($path, $deep, $e); 323 | } 324 | } 325 | 326 | public function move(string $source, string $destination, Config $config): void 327 | { 328 | if ($source === $destination) { 329 | return; 330 | } 331 | 332 | try { 333 | $this->copy($source, $destination, $config); 334 | $this->delete($source); 335 | } catch (Throwable $exception) { 336 | throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); 337 | } 338 | } 339 | 340 | public function copy(string $source, string $destination, Config $config): void 341 | { 342 | if ($source === $destination) { 343 | return; 344 | } 345 | 346 | try { 347 | $visibility = $config->get(Config::OPTION_VISIBILITY); 348 | 349 | if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { 350 | $visibility = $this->visibility($source)->visibility(); 351 | } 352 | } catch (Throwable $exception) { 353 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 354 | } 355 | 356 | $arguments = [ 357 | 'ACL' => $this->visibility->visibilityToAcl($visibility ?: 'private'), 358 | 'Bucket' => $this->bucket, 359 | 'Key' => $this->prefixer->prefixPath($destination), 360 | 'CopySource' => rawurlencode($this->bucket . '/' . $this->prefixer->prefixPath($source)), 361 | ]; 362 | 363 | try { 364 | $this->client->copyObject($arguments); 365 | } catch (Throwable $exception) { 366 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 367 | } 368 | } 369 | 370 | /** 371 | * @param string|resource $body 372 | */ 373 | private function upload(string $path, $body, Config $config): void 374 | { 375 | $key = $this->prefixer->prefixPath($path); 376 | $acl = $this->determineAcl($config); 377 | $options = $this->createOptionsFromConfig($config); 378 | $shouldDetermineMimetype = '' !== $body && ! \array_key_exists('ContentType', $options); 379 | 380 | if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) { 381 | $options['ContentType'] = $mimeType; 382 | } 383 | 384 | try { 385 | if ($this->client instanceof SimpleS3Client) { 386 | // Supports upload of files larger than 5GB 387 | $this->client->upload($this->bucket, $key, $body, array_merge($options, ['ACL' => $acl])); 388 | } else { 389 | $this->client->putObject(array_merge($options, [ 390 | 'Bucket' => $this->bucket, 391 | 'Key' => $key, 392 | 'Body' => $body, 393 | 'ACL' => $acl, 394 | ])); 395 | } 396 | } catch (Throwable $exception) { 397 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 398 | } 399 | } 400 | 401 | private function determineAcl(Config $config): string 402 | { 403 | $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE); 404 | 405 | return $this->visibility->visibilityToAcl($visibility); 406 | } 407 | 408 | private function createOptionsFromConfig(Config $config): array 409 | { 410 | $options = []; 411 | 412 | foreach ($this->forwardedOptions as $option) { 413 | $value = $config->get($option, '__NOT_SET__'); 414 | 415 | if ('__NOT_SET__' !== $value) { 416 | $options[$option] = $value; 417 | } 418 | } 419 | 420 | return $options; 421 | } 422 | 423 | private function fetchFileMetadata(string $path, string $type): FileAttributes 424 | { 425 | $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 426 | 427 | try { 428 | $result = $this->client->headObject($arguments); 429 | $result->resolve(); 430 | } catch (Throwable $exception) { 431 | throw UnableToRetrieveMetadata::create($path, $type, $exception->getMessage(), $exception); 432 | } 433 | 434 | $attributes = $this->mapS3ObjectMetadata($result, $path); 435 | 436 | if ( ! $attributes instanceof FileAttributes) { 437 | throw UnableToRetrieveMetadata::create($path, $type, 'Unable to retrieve file attributes, directory attributes received.'); 438 | } 439 | 440 | return $attributes; 441 | } 442 | 443 | /** 444 | * @param HeadObjectOutput|AwsObject|CommonPrefix $item 445 | */ 446 | private function mapS3ObjectMetadata($item, ?string $path = null): StorageAttributes 447 | { 448 | if (null === $path) { 449 | if ($item instanceof AwsObject) { 450 | $path = $this->prefixer->stripPrefix($item->getKey() ?? ''); 451 | } elseif ($item instanceof CommonPrefix) { 452 | $path = $this->prefixer->stripPrefix($item->getPrefix() ?? ''); 453 | } else { 454 | throw new \RuntimeException(sprintf('Argument 2 of "%s" cannot be null when $item is not instance of "%s" or %s', __METHOD__, AwsObject::class, CommonPrefix::class)); 455 | } 456 | } 457 | 458 | if ('/' === substr($path, -1)) { 459 | return new DirectoryAttributes(rtrim($path, '/')); 460 | } 461 | 462 | $mimeType = null; 463 | $fileSize = null; 464 | $lastModified = null; 465 | $dateTime = null; 466 | $metadata = []; 467 | 468 | if ($item instanceof AwsObject) { 469 | $dateTime = $item->getLastModified(); 470 | $fileSize = $item->getSize(); 471 | } elseif ($item instanceof CommonPrefix) { 472 | // No data available 473 | } elseif ($item instanceof HeadObjectOutput) { 474 | $mimeType = $item->getContentType(); 475 | $fileSize = $item->getContentLength(); 476 | $dateTime = $item->getLastModified(); 477 | $metadata = $this->extractExtraMetadata($item); 478 | } else { 479 | throw new \RuntimeException(sprintf('Object of class "%s" is not supported in %s()', \get_class($item), __METHOD__)); 480 | } 481 | 482 | if ($dateTime instanceof \DateTimeInterface) { 483 | $lastModified = $dateTime->getTimestamp(); 484 | } 485 | 486 | return new FileAttributes($path, $fileSize !== null ? (int) $fileSize : null, null, $lastModified, $mimeType, $metadata); 487 | } 488 | 489 | /** 490 | * @param HeadObjectOutput $metadata 491 | */ 492 | private function extractExtraMetadata($metadata): array 493 | { 494 | $extracted = []; 495 | 496 | foreach ($this->metadataFields as $field) { 497 | $method = 'get' . $field; 498 | if ( ! method_exists($metadata, $method)) { 499 | continue; 500 | } 501 | $value = $metadata->$method(); 502 | if (null !== $value) { 503 | $extracted[$field] = $value; 504 | } 505 | } 506 | 507 | return $extracted; 508 | } 509 | 510 | private function retrievePaginatedListing(array $options): Generator 511 | { 512 | $result = $this->client->listObjectsV2($options); 513 | 514 | foreach ($result as $item) { 515 | yield $item; 516 | } 517 | } 518 | 519 | private function readObject(string $path): ResultStream 520 | { 521 | $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 522 | 523 | try { 524 | return $this->client->getObject($options)->getBody(); 525 | } catch (Throwable $exception) { 526 | throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); 527 | } 528 | } 529 | 530 | public function publicUrl(string $path, Config $config): string 531 | { 532 | if ( ! $this->client instanceof SimpleS3Client) { 533 | throw UnableToGeneratePublicUrl::noGeneratorConfigured($path, 'Client needs to be instance of SimpleS3Client'); 534 | } 535 | 536 | try { 537 | return $this->client->getUrl($this->bucket, $this->prefixer->prefixPath($path)); 538 | } catch (Throwable $exception) { 539 | throw UnableToGeneratePublicUrl::dueToError($path, $exception); 540 | } 541 | } 542 | 543 | public function checksum(string $path, Config $config): string 544 | { 545 | $algo = $config->get('checksum_algo', 'etag'); 546 | 547 | if ($algo !== 'etag') { 548 | throw new ChecksumAlgoIsNotSupported(); 549 | } 550 | 551 | try { 552 | $metadata = $this->fetchFileMetadata($path, 'checksum')->extraMetadata(); 553 | } catch (UnableToRetrieveMetadata $exception) { 554 | throw new UnableToProvideChecksum($exception->reason(), $path, $exception); 555 | } 556 | 557 | if ( ! isset($metadata['ETag'])) { 558 | throw new UnableToProvideChecksum('ETag header not available.', $path); 559 | } 560 | 561 | return trim($metadata['ETag'], '"'); 562 | } 563 | 564 | public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string 565 | { 566 | try { 567 | $request = new GetObjectRequest([ 568 | 'Bucket' => $this->bucket, 569 | 'Key' => $this->prefixer->prefixPath($path), 570 | ] + $config->get('get_object_options', [])); 571 | 572 | return $this->client->presign($request, DateTimeImmutable::createFromInterface($expiresAt)); 573 | } catch (Throwable $exception) { 574 | throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); 575 | } 576 | } 577 | } 578 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /PortableVisibilityConverter.php: -------------------------------------------------------------------------------- 1 | defaultForDirectories = $defaultForDirectories; 25 | } 26 | 27 | public function visibilityToAcl(string $visibility): string 28 | { 29 | if (Visibility::PUBLIC === $visibility) { 30 | return self::PUBLIC_ACL; 31 | } 32 | 33 | return self::PRIVATE_ACL; 34 | } 35 | 36 | /** 37 | * @param Grant[] $grants 38 | */ 39 | public function aclToVisibility(array $grants): string 40 | { 41 | foreach ($grants as $grant) { 42 | if (null === $grantee = $grant->getGrantee()) { 43 | continue; 44 | } 45 | $granteeUri = $grantee->getURI(); 46 | $permission = $grant->getPermission(); 47 | 48 | if (self::PUBLIC_GRANTEE_URI === $granteeUri && self::PUBLIC_GRANTS_PERMISSION === $permission) { 49 | return Visibility::PUBLIC; 50 | } 51 | } 52 | 53 | return Visibility::PRIVATE; 54 | } 55 | 56 | public function defaultForDirectories(): string 57 | { 58 | return $this->defaultForDirectories; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /VisibilityConverter.php: -------------------------------------------------------------------------------- 1 |