├── VisibilityConverter.php ├── composer.json ├── LICENSE ├── PortableVisibilityConverter.php └── AwsS3V3Adapter.php /VisibilityConverter.php: -------------------------------------------------------------------------------- 1 | defaultForDirectories; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /AwsS3V3Adapter.php: -------------------------------------------------------------------------------- 1 | prefixer = new PathPrefixer($prefix); 114 | $this->visibility = $visibility ?? new PortableVisibilityConverter(); 115 | $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); 116 | } 117 | 118 | public function fileExists(string $path): bool 119 | { 120 | try { 121 | return $this->client->doesObjectExistV2($this->bucket, $this->prefixer->prefixPath($path), false, $this->options); 122 | } catch (Throwable $exception) { 123 | throw UnableToCheckFileExistence::forLocation($path, $exception); 124 | } 125 | } 126 | 127 | public function directoryExists(string $path): bool 128 | { 129 | try { 130 | $prefix = $this->prefixer->prefixDirectoryPath($path); 131 | $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix, 'MaxKeys' => 1, 'Delimiter' => '/']; 132 | $command = $this->client->getCommand('ListObjectsV2', $options); 133 | $result = $this->client->execute($command); 134 | 135 | return $result->hasKey('Contents') || $result->hasKey('CommonPrefixes'); 136 | } catch (Throwable $exception) { 137 | throw UnableToCheckDirectoryExistence::forLocation($path, $exception); 138 | } 139 | } 140 | 141 | public function write(string $path, string $contents, Config $config): void 142 | { 143 | $this->upload($path, $contents, $config); 144 | } 145 | 146 | /** 147 | * @param string $path 148 | * @param string|resource $body 149 | * @param Config $config 150 | */ 151 | private function upload(string $path, $body, Config $config): void 152 | { 153 | $key = $this->prefixer->prefixPath($path); 154 | $options = $this->createOptionsFromConfig($config); 155 | $acl = $options['params']['ACL'] ?? $this->determineAcl($config); 156 | $shouldDetermineMimetype = ! array_key_exists('ContentType', $options['params']); 157 | 158 | if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) { 159 | $options['params']['ContentType'] = $mimeType; 160 | } 161 | 162 | try { 163 | $this->client->upload($this->bucket, $key, $body, $acl, $options); 164 | } catch (Throwable $exception) { 165 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 166 | } 167 | } 168 | 169 | private function determineAcl(Config $config): string 170 | { 171 | $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE); 172 | 173 | return $this->visibility->visibilityToAcl($visibility); 174 | } 175 | 176 | private function createOptionsFromConfig(Config $config): array 177 | { 178 | $config = $config->withDefaults($this->options); 179 | $options = ['params' => []]; 180 | 181 | if ($mimetype = $config->get('mimetype')) { 182 | $options['params']['ContentType'] = $mimetype; 183 | } 184 | 185 | foreach ($this->forwardedOptions as $option) { 186 | $value = $config->get($option, '__NOT_SET__'); 187 | 188 | if ($value !== '__NOT_SET__') { 189 | $options['params'][$option] = $value; 190 | } 191 | } 192 | 193 | foreach ($this->multipartUploadOptions as $option) { 194 | $value = $config->get($option, '__NOT_SET__'); 195 | 196 | if ($value !== '__NOT_SET__') { 197 | $options[$option] = $value; 198 | } 199 | } 200 | 201 | return $options; 202 | } 203 | 204 | public function writeStream(string $path, $contents, Config $config): void 205 | { 206 | $this->upload($path, $contents, $config); 207 | } 208 | 209 | public function read(string $path): string 210 | { 211 | $body = $this->readObject($path, false); 212 | 213 | return (string) $body->getContents(); 214 | } 215 | 216 | public function readStream(string $path) 217 | { 218 | /** @var resource $resource */ 219 | $resource = $this->readObject($path, true)->detach(); 220 | 221 | return $resource; 222 | } 223 | 224 | public function delete(string $path): void 225 | { 226 | $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 227 | $command = $this->client->getCommand('DeleteObject', $arguments); 228 | 229 | try { 230 | $this->client->execute($command); 231 | } catch (Throwable $exception) { 232 | throw UnableToDeleteFile::atLocation($path, '', $exception); 233 | } 234 | } 235 | 236 | public function deleteDirectory(string $path): void 237 | { 238 | $prefix = $this->prefixer->prefixPath($path); 239 | $prefix = ltrim(rtrim($prefix, '/') . '/', '/'); 240 | 241 | try { 242 | $this->client->deleteMatchingObjects($this->bucket, $prefix); 243 | } catch (Throwable $exception) { 244 | throw UnableToDeleteDirectory::atLocation($path, '', $exception); 245 | } 246 | } 247 | 248 | public function createDirectory(string $path, Config $config): void 249 | { 250 | $defaultVisibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $this->visibility->defaultForDirectories()); 251 | $config = $config->withDefaults([Config::OPTION_VISIBILITY => $defaultVisibility]); 252 | $this->upload(rtrim($path, '/') . '/', '', $config); 253 | } 254 | 255 | public function setVisibility(string $path, string $visibility): void 256 | { 257 | $arguments = [ 258 | 'Bucket' => $this->bucket, 259 | 'Key' => $this->prefixer->prefixPath($path), 260 | 'ACL' => $this->visibility->visibilityToAcl($visibility), 261 | ]; 262 | $command = $this->client->getCommand('PutObjectAcl', $arguments); 263 | 264 | try { 265 | $this->client->execute($command); 266 | } catch (Throwable $exception) { 267 | throw UnableToSetVisibility::atLocation($path, '', $exception); 268 | } 269 | } 270 | 271 | public function visibility(string $path): FileAttributes 272 | { 273 | $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 274 | $command = $this->client->getCommand('GetObjectAcl', $arguments); 275 | 276 | try { 277 | $result = $this->client->execute($command); 278 | } catch (Throwable $exception) { 279 | throw UnableToRetrieveMetadata::visibility($path, '', $exception); 280 | } 281 | 282 | $visibility = $this->visibility->aclToVisibility((array) $result->get('Grants')); 283 | 284 | return new FileAttributes($path, null, $visibility); 285 | } 286 | 287 | private function fetchFileMetadata(string $path, string $type): FileAttributes 288 | { 289 | $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 290 | $command = $this->client->getCommand('HeadObject', $arguments); 291 | 292 | try { 293 | $result = $this->client->execute($command); 294 | } catch (Throwable $exception) { 295 | throw UnableToRetrieveMetadata::create($path, $type, '', $exception); 296 | } 297 | 298 | $attributes = $this->mapS3ObjectMetadata($result->toArray(), $path); 299 | 300 | if ( ! $attributes instanceof FileAttributes) { 301 | throw UnableToRetrieveMetadata::create($path, $type, ''); 302 | } 303 | 304 | return $attributes; 305 | } 306 | 307 | private function mapS3ObjectMetadata(array $metadata, string $path): StorageAttributes 308 | { 309 | if (substr($path, -1) === '/') { 310 | return new DirectoryAttributes(rtrim($path, '/')); 311 | } 312 | 313 | $mimetype = $metadata['ContentType'] ?? null; 314 | $fileSize = $metadata['ContentLength'] ?? $metadata['Size'] ?? null; 315 | $fileSize = $fileSize === null ? null : (int) $fileSize; 316 | $dateTime = $metadata['LastModified'] ?? null; 317 | $lastModified = $dateTime instanceof DateTimeResult ? $dateTime->getTimeStamp() : null; 318 | 319 | return new FileAttributes( 320 | $path, 321 | $fileSize, 322 | null, 323 | $lastModified, 324 | $mimetype, 325 | $this->extractExtraMetadata($metadata) 326 | ); 327 | } 328 | 329 | private function extractExtraMetadata(array $metadata): array 330 | { 331 | $extracted = []; 332 | 333 | foreach ($this->metadataFields as $field) { 334 | if (isset($metadata[$field]) && $metadata[$field] !== '') { 335 | $extracted[$field] = $metadata[$field]; 336 | } 337 | } 338 | 339 | return $extracted; 340 | } 341 | 342 | public function mimeType(string $path): FileAttributes 343 | { 344 | $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE); 345 | 346 | if ($attributes->mimeType() === null) { 347 | throw UnableToRetrieveMetadata::mimeType($path); 348 | } 349 | 350 | return $attributes; 351 | } 352 | 353 | public function lastModified(string $path): FileAttributes 354 | { 355 | $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED); 356 | 357 | if ($attributes->lastModified() === null) { 358 | throw UnableToRetrieveMetadata::lastModified($path); 359 | } 360 | 361 | return $attributes; 362 | } 363 | 364 | public function fileSize(string $path): FileAttributes 365 | { 366 | $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); 367 | 368 | if ($attributes->fileSize() === null) { 369 | throw UnableToRetrieveMetadata::fileSize($path); 370 | } 371 | 372 | return $attributes; 373 | } 374 | 375 | public function listContents(string $path, bool $deep): iterable 376 | { 377 | $prefix = trim($this->prefixer->prefixPath($path), '/'); 378 | $prefix = $prefix === '' ? '' : $prefix . '/'; 379 | $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix]; 380 | 381 | if ($deep === false) { 382 | $options['Delimiter'] = '/'; 383 | } 384 | 385 | $listing = $this->retrievePaginatedListing($options); 386 | 387 | foreach ($listing as $item) { 388 | $key = $item['Key'] ?? $item['Prefix']; 389 | 390 | if ($key === $prefix) { 391 | continue; 392 | } 393 | 394 | yield $this->mapS3ObjectMetadata($item, $this->prefixer->stripPrefix($key)); 395 | } 396 | } 397 | 398 | private function retrievePaginatedListing(array $options): Generator 399 | { 400 | $resultPaginator = $this->client->getPaginator('ListObjectsV2', $options + $this->options); 401 | 402 | foreach ($resultPaginator as $result) { 403 | yield from ($result->get('CommonPrefixes') ?? []); 404 | yield from ($result->get('Contents') ?? []); 405 | } 406 | } 407 | 408 | public function move(string $source, string $destination, Config $config): void 409 | { 410 | if ($source === $destination) { 411 | return; 412 | } 413 | 414 | try { 415 | $this->copy($source, $destination, $config); 416 | $this->delete($source); 417 | } catch (FilesystemOperationFailed $exception) { 418 | throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); 419 | } 420 | } 421 | 422 | public function copy(string $source, string $destination, Config $config): void 423 | { 424 | if ($source === $destination) { 425 | return; 426 | } 427 | 428 | try { 429 | $visibility = $config->get(Config::OPTION_VISIBILITY); 430 | 431 | if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { 432 | $visibility = $this->visibility($source)->visibility(); 433 | } 434 | } catch (Throwable $exception) { 435 | throw UnableToCopyFile::fromLocationTo( 436 | $source, 437 | $destination, 438 | $exception 439 | ); 440 | } 441 | 442 | $options = $this->createOptionsFromConfig($config); 443 | $options['MetadataDirective'] = $config->get('MetadataDirective', 'COPY'); 444 | 445 | try { 446 | $this->client->copy( 447 | $this->bucket, 448 | $this->prefixer->prefixPath($source), 449 | $this->bucket, 450 | $this->prefixer->prefixPath($destination), 451 | $this->visibility->visibilityToAcl($visibility ?: 'private'), 452 | $options, 453 | ); 454 | } catch (Throwable $exception) { 455 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 456 | } 457 | } 458 | 459 | private function readObject(string $path, bool $wantsStream): StreamInterface 460 | { 461 | $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; 462 | 463 | if ($wantsStream && $this->streamReads && ! isset($this->options['@http']['stream'])) { 464 | $options['@http']['stream'] = true; 465 | } 466 | 467 | $command = $this->client->getCommand('GetObject', $options + $this->options); 468 | 469 | try { 470 | return $this->client->execute($command)->get('Body'); 471 | } catch (Throwable $exception) { 472 | throw UnableToReadFile::fromLocation($path, '', $exception); 473 | } 474 | } 475 | 476 | public function publicUrl(string $path, Config $config): string 477 | { 478 | $location = $this->prefixer->prefixPath($path); 479 | 480 | try { 481 | return $this->client->getObjectUrl($this->bucket, $location); 482 | } catch (Throwable $exception) { 483 | throw UnableToGeneratePublicUrl::dueToError($path, $exception); 484 | } 485 | } 486 | 487 | public function checksum(string $path, Config $config): string 488 | { 489 | $algo = $config->get('checksum_algo', 'etag'); 490 | 491 | if ($algo !== 'etag') { 492 | throw new ChecksumAlgoIsNotSupported(); 493 | } 494 | 495 | try { 496 | $metadata = $this->fetchFileMetadata($path, 'checksum')->extraMetadata(); 497 | } catch (UnableToRetrieveMetadata $exception) { 498 | throw new UnableToProvideChecksum($exception->reason(), $path, $exception); 499 | } 500 | 501 | if ( ! isset($metadata['ETag'])) { 502 | throw new UnableToProvideChecksum('ETag header not available.', $path); 503 | } 504 | 505 | return trim($metadata['ETag'], '"'); 506 | } 507 | 508 | public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string 509 | { 510 | try { 511 | $options = $config->get('get_object_options', []); 512 | $command = $this->client->getCommand('GetObject', [ 513 | 'Bucket' => $this->bucket, 514 | 'Key' => $this->prefixer->prefixPath($path), 515 | ] + $options); 516 | 517 | $presignedRequestOptions = $config->get('presigned_request_options', []); 518 | $request = $this->client->createPresignedRequest($command, $expiresAt, $presignedRequestOptions); 519 | 520 | return (string) $request->getUri(); 521 | } catch (Throwable $exception) { 522 | throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); 523 | } 524 | } 525 | } 526 | --------------------------------------------------------------------------------