PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
← All changes | includes/base/services/s3.php +530 -178 1.2.5 → 1.4.2 View file →
@@ -8,8 +8,10 @@
8 8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 +use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 +use Dudlewebs\WPMCS\s3\Aws\Command;
12 14 use Exception;
13 15
14 16 class S3 {
15 17 private $assets_url;
@@ -29,23 +31,26 @@
29 31 /**
30 32 * Admin constructor.
31 33 * @since 1.0.0
32 34 */
33 - public function __construct() {
35 + public function __construct($credentials = null) {
34 36 $this->assets_url = WPMCS_ASSETS_URL;
35 37 $this->version = WPMCS_VERSION;
36 38 $this->token = WPMCS_TOKEN;
37 39
38 40 // Initialize setup
39 - $this->init();
41 + $this->init($credentials);
40 42 }
41 43
42 44 /**
43 45 * Initialise Client
46 + *
47 + * @param array|null $credentials Optional explicit credentials; falls back to
48 + * Utils::get_credentials() when omitted.
44 49 */
45 - public function init() {
50 + public function init($credentials = null) {
46 51 $this->settings = Utils::get_settings();
47 - $this->credentials = Utils::get_credentials();
52 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
48 53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
49 54 ? $this->credentials['config']
50 55 : [];
51 56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -83,14 +88,14 @@
83 88 * Verify Credentials
84 89 * @since 1.0.0
85 90 * @return boolean
86 91 */
87 - public function verifyCredentials($access_key, $secret_key, $region){
88 - if (
89 - isset($region) && !empty($region) &&
90 - isset($access_key) && !empty($access_key) &&
91 - isset($secret_key) && !empty($secret_key)
92 - ) {
92 + public function verifyCredentials($config = []) {
93 + $region = isset($config['region']) ? $config['region'] : '';
94 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
95 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
96 +
97 + if (!Service::has_missing_fields([$region, $access_key, $secret_key])) {
93 98 try {
94 99 $s3Client = new S3Client([
95 100 'version' => '2006-03-01',
96 101 'region' => $region,
@@ -126,8 +131,9 @@
126 131 'NoSuchBucket',
127 132 'AllAccessDisabled',
128 133 'AuthorizationHeaderMalformed',
129 134 'PermanentRedirect',
135 + 'InvalidBucketName',
130 136 ];
131 137
132 138 if (in_array($code, $validErrors)) {
133 139 // If we reach here, the credentials are valid
@@ -180,15 +186,16 @@
180 186 * Verify Bucket Exist
181 187 * @since 1.0.0
182 188 * @return boolean
183 189 */
184 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration=false){
185 - if (
186 - isset($region) && !empty($region) &&
187 - isset($access_key) && !empty($access_key) &&
188 - isset($secret_key) && !empty($secret_key) &&
189 - isset($bucket_name) && !empty($bucket_name)
190 - ) {
190 + public function verifyBucketExist( $config = [], $bucketConfig = [] ) {
191 + $region = isset($config['region']) ? $config['region'] : '';
192 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
193 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
194 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
195 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
196 +
197 + if (!Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
191 198 try {
192 199 $s3Client = new S3Client([
193 200 'version' => '2006-03-01',
194 201 'region' => $region,
@@ -234,10 +241,16 @@
234 241 * Create Bucket
235 242 * @since 1.0.0
236 243 * @return boolean
237 244 */
238 - public function createBucket($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
239 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
245 + public function createBucket( $config = [], $bucketConfig = [] ) {
246 + $region = isset($config['region']) ? $config['region'] : '';
247 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
248 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
249 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
250 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
251 +
252 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
240 253 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
241 254 }
242 255
243 256 try {
@@ -302,10 +315,16 @@
302 315 /**
303 316 * Check Bucket Write Permission
304 317 * @since 1.0.0
305 318 */
306 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
307 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
319 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
320 + $region = isset($config['region']) ? $config['region'] : '';
321 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
322 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
323 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
324 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
325 +
326 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
308 327 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
309 328 }
310 329
311 330 try {
@@ -321,9 +340,9 @@
321 340 ];
322 341
323 342 $s3Client = new S3Client($s3ClientConfig);
324 343
325 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
344 + $object_key = Utils::get_permission_check_object_key();
326 345
327 346
328 347 // Create a dummy object to check write permission
329 348 $s3Client->putObject([
@@ -331,9 +350,9 @@
331 350 'Key' => $object_key,
332 351 'Body' => 'This is a test object to check write permission.',
333 352 ]);
334 353 // Check if the object was created successfully
335 - if ($s3Client->doesObjectExist($bucket_name, $object_key)) {
354 + if ($this->exists($object_key, $bucket_name, $s3Client)) {
336 355 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
337 356 } else {
338 357 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
339 358 }
@@ -352,10 +371,16 @@
352 371 /**
353 372 * Check Bucket Delete Permission
354 373 * @since 1.0.0
355 374 */
356 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
357 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
375 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
376 + $region = isset($config['region']) ? $config['region'] : '';
377 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
378 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
379 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
380 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
381 +
382 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
358 383 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
359 384 }
360 385
361 386 try {
@@ -371,9 +396,9 @@
371 396 ];
372 397
373 398 $s3Client = new S3Client($s3ClientConfig);
374 399
375 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
400 + $object_key = Utils::get_permission_check_object_key();
376 401
377 402 // Create a dummy object to check dlete permission
378 403 $s3Client->deleteObject([
379 404 'Bucket' => $bucket_name,
@@ -380,9 +405,9 @@
380 405 'Key' => $object_key,
381 406 ]);
382 407
383 408 // Check if the object was created successfully
384 - if (!$s3Client->doesObjectExist($bucket_name, $object_key)) {
409 + if (!$this->exists($object_key, $bucket_name, $s3Client)) {
385 410 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
386 411 } else {
387 412 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
388 413 }
@@ -401,49 +426,59 @@
401 426 * Check Bucket Read Permission
402 427 * @since 1.2.4
403 428 */
404 429 public function verifyObjectReadPermission() {
405 - if (empty($this->s3Client) || empty($this->bucket_name)) {
406 - return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false];
430 + $result = [
431 + 'status' => false,
432 + 'message' => '',
433 + 'lastChecked' => time(),
434 + ];
435 +
436 + if (Service::has_missing_fields([$this->s3Client, $this->bucket_name])) {
437 + $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
438 + return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
407 439 }
408 440
409 441 try {
410 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
442 + $object_key = Utils::get_permission_check_object_key();
411 443
412 444 // Check if the object was created successfully
413 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $object_key)) {
445 + if (!$this->exists($object_key)) {
414 446 // Create a dummy object to check write permission
415 447 $this->s3Client->putObject([
416 448 'Bucket' => $this->bucket_name,
417 449 'Key' => $object_key,
418 450 'Body' => 'This is a test object to check permission.',
451 + 'ContentType' => 'text/plain',
452 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
419 453 ]);
420 - }
421 -
454 + }
422 455
456 +
423 457 $url = $this->generate_file_url($object_key);
424 458 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
425 - $headers = @get_headers($cdn_url);
426 - $result = [
427 - 'status' => false,
428 - 'message' => '',
429 - 'lastChecked' => time(),
430 - ];
431 - if (strpos($headers[0], '200') !== false) {
459 + // Never trust a cached response for this fixed, predictable URL — a stale cached
460 + // error would otherwise keep failing the check long after real access is fine.
461 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
462 + $headers = @get_headers($cdn_url, false, $no_cache_context);
463 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
464 + ? (int) $matches[1]
465 + : 0;
466 +
467 + if ($status_code === 200) {
432 468 $result['status'] = true;
433 469 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
434 - } else if (strpos($headers[0], '403') !== false) {
470 + } else if ($status_code === 403) {
435 471 $result['status'] = false;
436 - if($this->cdnConfig['service'] == $this->service) {
472 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
437 473 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
438 474 } else {
439 475 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
440 476 }
441 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
442 - } else if (strpos($headers[0], '404') !== false) {
477 + } else if ($status_code === 404) {
443 478 $result['status'] = false;
444 479 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
445 - } else if (strpos($headers[0], '500') !== false) {
480 + } else if ($status_code === 500) {
446 481 $result['status'] = false;
447 482 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
448 483 } else {
449 484 $result['status'] = false;
@@ -448,9 +483,8 @@
448 483 } else {
449 484 $result['status'] = false;
450 485 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
451 486 }
452 - Utils::set_status('cdnRead', $result);
453 487
454 488 $this->deleteSingle($object_key);
455 489 return [
456 490 'message' => $result['message'],
@@ -458,13 +492,16 @@
458 492 'success' => $result['status'],
459 493 'lastChecked' => $result['lastChecked'],
460 494 ];
461 495 } catch (AwsException $ex) {
462 - return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
496 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
497 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
463 498 } catch (S3Exception $ex) {
464 - return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
499 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
500 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
465 501 } catch (Exception $ex) {
466 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
502 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
503 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
467 504 }
468 505 }
469 506
470 507
@@ -470,9 +507,15 @@
470 507
471 508 /**
472 509 * get Bucket Security Settings
473 510 */
474 - public function getBucketSecuritySettings($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
511 + public function getBucketSecuritySettings( $config = [], $bucketConfig = [] ) {
512 + $region = isset($config['region']) ? $config['region'] : '';
513 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
514 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
515 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
516 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
517 +
475 518 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
476 519 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
477 520 }
478 521
@@ -498,9 +541,8 @@
498 541 'Bucket' => $bucket_name,
499 542 ]);
500 543
501 544 $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
502 - $security = [];
503 545 if (
504 546 $publicAccessBlockConfig['BlockPublicAcls'] &&
505 547 $publicAccessBlockConfig['IgnorePublicAcls'] &&
506 548 $publicAccessBlockConfig['BlockPublicPolicy'] &&
@@ -548,9 +590,15 @@
548 590 /**
549 591 * Change Bucket Public Access
550 592 */
551 593
552 - public function changePublicAccess($value, $access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
594 + public function changePublicAccess( $config = [], $bucketConfig = [], $value = false ) {
595 + $region = isset($config['region']) ? $config['region'] : '';
596 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
597 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
598 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
599 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
600 +
553 601 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
554 602 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
555 603 }
556 604
@@ -592,9 +640,15 @@
592 640 /**
593 641 * Change Bucket Ownership
594 642 */
595 643
596 - public function changeObjectOwnership($value, $access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
644 + public function changeObjectOwnership( $config = [], $bucketConfig = [], $value = false ) {
645 + $region = isset($config['region']) ? $config['region'] : '';
646 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
647 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
648 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
649 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
650 +
597 651 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
598 652 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
599 653 }
600 654
@@ -667,10 +721,18 @@
667 721 }
668 722
669 723 /**
670 724 * Add Bucket Policy
725 + *
726 + * $private_prefix, when non-empty, carves that path out of the public
727 + * grant entirely — every action in the list, not just reads, so an
728 + * anonymous caller can't read, write, or delete anything under it. Kept
729 + * as one statement with NotResource rather than split into "reads
730 + * excluded, everything else still public" — that split would still let
731 + * anonymous PutObject/DeleteObject reach a "private" file.
732 + * @since 1.4.1 $private_prefix param added.
671 733 */
672 - private function putBucketPolicy($bucket, $s3Client = false) {
734 + private function putBucketPolicy($bucket, $s3Client = false, $private_prefix = '') {
673 735 if($s3Client == false) {
674 736 $s3Client = $this->s3Client;
675 737 }
676 738
@@ -675,38 +737,45 @@
675 737 }
676 738
677 739 if(empty($bucket)) return false;
678 740
741 + $actions = [
742 + "s3:DeleteObjectTagging",
743 + "s3:ListBucketMultipartUploads",
744 + "s3:DeleteObjectVersion",
745 + "s3:ListBucket",
746 + "s3:DeleteObjectVersionTagging",
747 + "s3:GetBucketAcl",
748 + "s3:ListMultipartUploadParts",
749 + "s3:PutObject",
750 + "s3:GetObjectAcl",
751 + "s3:GetObject",
752 + "s3:AbortMultipartUpload",
753 + "s3:DeleteObject",
754 + "s3:GetBucketLocation",
755 + "s3:PutObjectAcl",
756 + "s3:putBucketOwnershipControls",
757 + "s3:putBucketPolicy"
758 + ];
759 +
760 + $statement = [
761 + "Effect" => "Allow",
762 + "Principal" => "*",
763 + "Action" => $actions,
764 + ];
765 +
766 + if (!empty($private_prefix)) {
767 + $statement["NotResource"] = ["arn:aws:s3:::$bucket/$private_prefix/*"];
768 + } else {
769 + $statement["Resource"] = [
770 + "arn:aws:s3:::$bucket/*",
771 + "arn:aws:s3:::$bucket"
772 + ];
773 + }
774 +
679 775 $policy = json_encode([
680 - "Version" => "2012-10-17",
681 - "Statement" => [
682 - [
683 - "Effect" => "Allow",
684 - "Principal" => "*",
685 - "Action" => [
686 - "s3:DeleteObjectTagging",
687 - "s3:ListBucketMultipartUploads",
688 - "s3:DeleteObjectVersion",
689 - "s3:ListBucket",
690 - "s3:DeleteObjectVersionTagging",
691 - "s3:GetBucketAcl",
692 - "s3:ListMultipartUploadParts",
693 - "s3:PutObject",
694 - "s3:GetObjectAcl",
695 - "s3:GetObject",
696 - "s3:AbortMultipartUpload",
697 - "s3:DeleteObject",
698 - "s3:GetBucketLocation",
699 - "s3:PutObjectAcl",
700 - "s3:putBucketOwnershipControls",
701 - "s3:putBucketPolicy"
702 - ],
703 - "Resource" => [
704 - "arn:aws:s3:::$bucket/*",
705 - "arn:aws:s3:::$bucket"
706 - ]
707 - ]
708 - ]
776 + "Version" => "2012-10-17",
777 + "Statement" => [$statement]
709 778 ]);
710 779
711 780 try {
712 781 // Add bucket policy
@@ -722,8 +791,25 @@
722 791 }
723 792 }
724 793
725 794 /**
795 + * Apply (or, with an empty $private_prefix, un-apply) the private-path
796 + * bucket policy carve-out.
797 + * @since 1.4.1
798 + */
799 + public function applyPrivatePathPolicy($private_prefix) {
800 + if (!$this->s3Client || empty($this->bucket_name)) {
801 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
802 + }
803 +
804 + $ok = $this->putBucketPolicy($this->bucket_name, $this->s3Client, $private_prefix);
805 +
806 + return $ok
807 + ? ['success' => true, 'code' => 200, 'message' => esc_html__('Policy applied successfully', 'media-cloud-sync')]
808 + : ['success' => false, 'code' => 200, 'message' => esc_html__('Failed to apply bucket policy', 'media-cloud-sync')];
809 + }
810 +
811 + /**
726 812 * Add Bucket Ownership
727 813 */
728 814 private function changeBucketOwnership($bucket, $s3Client = false, $ownership = 'BucketOwnerPreferred') {
729 815 if($s3Client == false) {
@@ -793,9 +879,9 @@
793 879
794 880 // If we reach here, the credentials are valid
795 881 return true;
796 882 } catch (AwsException $ex) {
797 - $code = $e->getAwsErrorCode();
883 + $code = $ex->getAwsErrorCode();
798 884
799 885 $validErrors = [
800 886 'AccessDenied',
801 887 'NoSuchBucket',
@@ -801,8 +887,9 @@
801 887 'NoSuchBucket',
802 888 'AllAccessDisabled',
803 889 'AuthorizationHeaderMalformed',
804 890 'PermanentRedirect',
891 + 'InvalidBucketName'
805 892 ];
806 893
807 894 if (in_array($code, $validErrors)) {
808 895 // If we reach here, the credentials are valid
@@ -825,8 +912,9 @@
825 912 *
826 913 */
827 914 public function toPrivate($key) {
828 915 if(!$key) return false;
916 + if(!$this->s3Client) return false;
829 917 try {
830 918 $this->s3Client->putObjectAcl([
831 919 'Bucket' => $this->bucket_name,
832 920 'Key' => $key,
@@ -835,9 +923,8 @@
835 923 return true;
836 924 } catch (AwsException $ex) {
837 925 return false;
838 926 }
839 - return false;
840 927 }
841 928
842 929
843 930
@@ -843,23 +930,23 @@
843 930
844 931 /**
845 932 * Make Object Public
846 933 * @since 1.0.0
847 - *
934 + *
848 935 */
849 936 public function toPublic($key) {
850 937 if(!$key) return false;
938 + if(!$this->s3Client) return false;
851 939 try {
852 940 $this->s3Client->putObjectAcl([
853 941 'Bucket' => $this->bucket_name,
854 942 'Key' => $key,
855 943 'ACL' => 'public-read'
856 - ]);
944 + ]);
857 945 return true;
858 946 } catch (AwsException $ex) {
859 947 return false;
860 948 }
861 - return false;
862 949 }
863 950
864 951
865 952
@@ -866,14 +953,17 @@
866 953 /**
867 954 * Check the object exist
868 955 * @since 1.1.8
869 956 */
870 - public function exists($key) {
957 + public function exists($key, $bucket_name = '', $client = null) {
871 958 if(!$key) return false;
872 959 try {
873 - if($this->s3Client->doesObjectExist($this->bucket_name, $key)) {
960 + $bucket_name = $bucket_name ? $bucket_name : $this->bucket_name;
961 + $client = $client ?? $this->s3Client;
962 + if($client->doesObjectExistV2( $bucket_name, $key)) {
874 963 return true;
875 964 }
965 + return false;
876 966 } catch (AwsException $ex) {
877 967 return false;
878 968 } catch (S3Exception $ex) {
879 969 return false;
@@ -882,101 +972,188 @@
882 972 }
883 973 }
884 974
885 975 /**
976 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
977 + * @since 1.3.13
978 + */
979 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
980 + if (!$this->s3Client) {
981 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
982 + }
983 + try {
984 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
985 + if (!empty($delimiter)) {
986 + $params['Delimiter'] = $delimiter;
987 + }
988 + if (!empty($prefix)) {
989 + $params['Prefix'] = $prefix;
990 + }
991 + if (!empty($continuationToken)) {
992 + $params['ContinuationToken'] = $continuationToken;
993 + }
994 +
995 + $result = $this->s3Client->listObjectsV2($params);
996 + $folders = [];
997 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
998 + $folders[] = $common['Prefix'];
999 + }
1000 + $objects = [];
1001 + foreach (($result['Contents'] ?? []) as $object) {
1002 + if ($object['Key'] === $prefix) {
1003 + continue; // the folder placeholder object itself, not a file
1004 + }
1005 + $objects[] = [
1006 + 'key' => $object['Key'],
1007 + 'size' => (int) $object['Size'],
1008 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
1009 + ];
1010 + }
1011 +
1012 + return [
1013 + 'success' => true,
1014 + 'code' => 200,
1015 + 'message' => '',
1016 + 'folders' => $folders,
1017 + 'objects' => $objects,
1018 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
1019 + ];
1020 + } catch (AwsException $e) {
1021 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
1022 + } catch (Exception $e) {
1023 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
1024 + }
1025 + }
1026 +
1027 + /**
886 1028 * Upload Single
887 1029 * @since 1.0.0
888 1030 * @return boolean
889 1031 */
890 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
891 - $result = array();
1032 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
892 1033 if (
893 - isset($media_absolute_path) && !empty($media_absolute_path) &&
894 - isset($media_path) && !empty($media_path)
1034 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1035 + isset($relative_source_path) && !empty($relative_source_path)
895 1036 ) {
896 - $file_name = wp_basename( $media_path );
1037 + $file_name = wp_basename( $relative_source_path );
897 1038 if ($file_name) {
898 - $upload_path = Utils::generate_object_key($media_path, $prefix);
899 -
900 - // Decide Multipart upload or normal put object
901 - if (filesize($media_absolute_path) <= Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE')) {
902 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
903 - try {
904 - $upload = $this->s3Client->putObject([
905 - 'Bucket' => $this->bucket_name,
906 - 'Key' => $upload_path,
907 - 'Body' => fopen($media_absolute_path, 'r'),
908 - ]);
1039 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
1040 + if ($upload_path === false) {
1041 + // Only happens for a private reupload with no private-path provider
1042 + // available (Pro inactive/unlicensed) — refuse rather than upload
1043 + // an already-private file to an unprotected path.
1044 + return [
1045 + 'success' => false,
1046 + 'code' => 200,
1047 + 'message' => esc_html__('This file is marked private, but the private-media add-on is not currently active — reupload skipped to avoid exposing it.', 'media-cloud-sync')
1048 + ];
1049 + }
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
1051 + }
1052 + return [
1053 + 'success' => false,
1054 + 'code' => 200,
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
1056 + ];
1057 + }
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1062 + ];
1063 + }
909 1064
910 - $result = array(
911 - 'success' => true,
912 - 'code' => 200,
913 - 'file_url' => $this->generate_file_url($upload_path),
914 - 'key' => $upload_path,
915 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
916 - );
917 - } catch (AwsException $e) {
918 - $result = array(
919 - 'success' => false,
920 - 'code' => 200,
921 - 'message' => $e->getMessage()
922 - );
923 - }
924 - } else {
925 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
926 - 'bucket' => $this->bucket_name,
927 - 'key' => $upload_path,
928 - ]);
929 -
930 - try {
931 - do {
932 - try {
933 - $uploaded = $multiUploader->upload();
934 - } catch (MultipartUploadException $e) {
935 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
936 - 'state' => $e->getState(),
937 - ]);
938 - }
939 - } while (!isset($uploaded));
1065 + /**
1066 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
1067 + * @since 1.4.0
1068 + */
1069 + public function uploadObjectAtKey($absolute_source_path, $key) {
1070 + return $this->execute_upload($absolute_source_path, $key);
1071 + }
940 1072
941 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
942 - $result = array(
943 - 'success' => true,
944 - 'code' => 200,
945 - 'file_url' => $this->generate_file_url($upload_path),
946 - 'key' => $upload_path,
947 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
948 - );
949 - } else {
950 - $result = array(
951 - 'success' => false,
952 - 'code' => 200,
953 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
954 - );
955 - }
956 - } catch (MultipartUploadException $e) {
957 - $result = array(
958 - 'success' => false,
959 - 'code' => 200,
960 - 'message' => $e->getMessage()
961 - );
962 - }
1073 + /**
1074 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
1075 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
1076 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
1077 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
1078 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
1079 + * previously-failed multipart attempt).
1080 + * @since 1.4.0
1081 + */
1082 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
1083 + $handle = fopen($absolute_source_path, 'rb');
1084 + $params = [];
1085 + $cache_control = Utils::get_cache_control_header();
1086 + if ($cache_control) {
1087 + $params['CacheControl'] = $cache_control;
1088 + }
1089 + $options += [
1090 + 'mup_threshold' => Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE'),
1091 + 'params' => $params,
1092 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
1093 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
1094 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
1095 + ];
1096 + return new ObjectUploader($this->s3Client, $this->bucket_name, $key, $handle, null, $options);
1097 + }
1098 +
1099 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
1100 + // return value, relying on the same Command object being modified.
1101 + private function strip_acl($params) {
1102 + if ($params instanceof Command && $params->hasParam('ACL')) {
1103 + unset($params['ACL']);
1104 + } elseif (is_array($params) && isset($params['ACL'])) {
1105 + unset($params['ACL']);
1106 + }
1107 + return $params;
1108 + }
1109 +
1110 + /**
1111 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
1112 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
1113 + * state rather than restarting the whole upload — same retry contract uploadSingle()
1114 + * had before the ObjectUploader swap.
1115 + * @since 1.4.0
1116 + */
1117 + private function execute_upload($absolute_source_path, $key) {
1118 + $max_attempts = 3;
1119 + $attempt = 0;
1120 + $options = [];
1121 +
1122 + while (true) {
1123 + $attempt++;
1124 + try {
1125 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
1126 + return [
1127 + 'success' => true,
1128 + 'code' => 200,
1129 + 'file_url' => $this->generate_file_url($key),
1130 + 'key' => $key,
1131 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
1132 + ];
1133 + } catch (MultipartUploadException $e) {
1134 + if ($attempt >= $max_attempts) {
1135 + return [
1136 + 'success' => false,
1137 + 'code' => 200,
1138 + 'message' => $e->getMessage()
1139 + ];
963 1140 }
964 - } else {
965 - $result = array(
1141 + $options = ['state' => $e->getState()];
1142 + } catch (AwsException $e) {
1143 + return [
966 1144 'success' => false,
967 1145 'code' => 200,
968 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
969 - );
1146 + 'message' => $e->getMessage()
1147 + ];
1148 + } catch (Exception $e) {
1149 + return [
1150 + 'success' => false,
1151 + 'code' => 200,
1152 + 'message' => $e->getMessage()
1153 + ];
970 1154 }
971 - } else {
972 - $result = array(
973 - 'success' => false,
974 - 'code' => 200,
975 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
976 - );
977 1155 }
978 - return $result;
979 1156 }
980 1157
981 1158 /**
982 1159 * Save object to server
@@ -982,8 +1159,9 @@
982 1159 * Save object to server
983 1160 * @since 1.0.0
984 1161 */
985 1162 public function object_to_server($key, $save_path) {
1163 + if(!$this->s3Client) return false;
986 1164 try {
987 1165 $getObject = $this->s3Client->GetObject([
988 1166 'Bucket' => $this->bucket_name,
989 1167 'Key' => $key,
@@ -997,10 +1175,156 @@
997 1175 }
998 1176 return false;
999 1177 }
1000 1178
1179 + /**
1180 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
1181 + * the content itself rather than a copy on the server's filesystem.
1182 + * @since 1.3.13
1183 + */
1184 + public function get_object_content($key) {
1185 + if(!$this->s3Client) return false;
1186 + try {
1187 + $result = $this->s3Client->GetObject([
1188 + 'Bucket' => $this->bucket_name,
1189 + 'Key' => $key,
1190 + ]);
1191 + return (string) $result['Body'];
1192 + } catch (AwsException $e) {
1193 + return false;
1194 + }
1195 + }
1001 1196
1002 1197 /**
1198 + * Deletes the live object, then best-effort purges every historical version too — a
1199 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
1200 + * versions (and the storage they use) behind at the old key. The live delete happens
1201 + * unconditionally first: not every S3-compatible endpoint supports ListObjectVersions
1202 + * (confirmed missing on Cloudflare R2, a live 501 "NotImplemented"), and the object must
1203 + * still end up gone either way.
1204 + * @since 1.3.14
1205 + */
1206 + public function purge_all_versions($key) {
1207 + if (!$this->s3Client) {
1208 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
1209 + }
1210 +
1211 + try {
1212 + $this->s3Client->deleteObject([
1213 + 'Bucket' => $this->bucket_name,
1214 + 'Key' => $key,
1215 + ]);
1216 + } catch (AwsException $e) {
1217 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1218 + }
1219 +
1220 + // Best-effort only from here — providers that don't support version listing simply
1221 + // skip this part; the live object above is already gone regardless.
1222 + try {
1223 + $objects = [];
1224 + $marker = null;
1225 + do {
1226 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
1227 + if ($marker) {
1228 + $args['KeyMarker'] = $marker['key'];
1229 + $args['VersionIdMarker'] = $marker['version'];
1230 + }
1231 + $result = $this->s3Client->listObjectVersions($args);
1232 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
1233 + if (($version['Key'] ?? null) === $key) {
1234 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
1235 + }
1236 + }
1237 + $marker = !empty($result['IsTruncated'])
1238 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
1239 + : null;
1240 + } while ($marker);
1241 +
1242 + foreach (array_chunk($objects, 1000) as $chunk) {
1243 + $this->s3Client->deleteObjects([
1244 + 'Bucket' => $this->bucket_name,
1245 + 'Delete' => ['Objects' => $chunk],
1246 + ]);
1247 + }
1248 + } catch (AwsException $e) {
1249 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
1250 + }
1251 +
1252 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
1253 + }
1254 +
1255 +
1256 + /**
1257 + * Copy object to new path
1258 + * @since 1.3.4
1259 + */
1260 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
1261 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
1262 + // processing keys sequentially, three extra round-trips per file adds up fast on a
1263 + // folder with many files. copyObject() itself throws (caught below) if the source is
1264 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
1265 + public function copy_to_new_path($key, $new_path) {
1266 + if (!$this->s3Client) {
1267 + return [
1268 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1269 + 'code' => 200,
1270 + 'success' => false
1271 + ];
1272 + }
1273 + try {
1274 + $this->s3Client->copyObject([
1275 + 'Bucket' => $this->bucket_name,
1276 + 'CopySource' => "{$this->bucket_name}/{$key}",
1277 + 'Key' => $new_path,
1278 + 'MetadataDirective' => 'COPY',
1279 + ]);
1280 + return [
1281 + 'success' => true,
1282 + 'code' => 200,
1283 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1284 + ];
1285 + } catch (AwsException $e) {
1286 + return [
1287 + 'success' => false,
1288 + 'code' => 200,
1289 + 'message' => $e->getMessage()
1290 + ];
1291 + }
1292 + }
1293 +
1294 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
1295 + // access there too, so callers should fall back to download+upload on failure.
1296 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
1297 + if (!$this->s3Client) {
1298 + return [
1299 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1300 + 'code' => 200,
1301 + 'success' => false
1302 + ];
1303 + }
1304 + try {
1305 + $this->s3Client->copyObject([
1306 + 'Bucket' => $dest_bucket,
1307 + 'CopySource' => "{$this->bucket_name}/{$key}",
1308 + 'Key' => $new_key,
1309 + 'MetadataDirective' => 'COPY',
1310 + ]);
1311 + return [
1312 + 'success' => true,
1313 + 'code' => 200,
1314 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1315 + ];
1316 + } catch (AwsException $e) {
1317 + return [
1318 + 'success' => false,
1319 + 'code' => 200,
1320 + 'message' => $e->getMessage()
1321 + ];
1322 + }
1323 + }
1324 +
1325 +
1326 + /**
1003 1327 * Delete Single
1004 1328 * @since 1.0.0
1005 1329 * @return boolean
1006 1330 */
@@ -1005,8 +1329,15 @@
1005 1329 * @return boolean
1006 1330 */
1007 1331 public function deleteSingle($key) {
1008 1332 $result = array();
1333 + if (!$this->s3Client) {
1334 + return array(
1335 + 'success' => false,
1336 + 'code' => 200,
1337 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1338 + );
1339 + }
1009 1340 if (isset($key) && !empty($key)) {
1010 1341 try {
1011 1342 $this->s3Client->deleteObject([
1012 1343 'Bucket' => $this->bucket_name,
@@ -1012,9 +1343,9 @@
1012 1343 'Bucket' => $this->bucket_name,
1013 1344 'Key' => $key
1014 1345 ]);
1015 1346
1016 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $key)) {
1347 + if (!$this->exists($key)) {
1017 1348 $result = array(
1018 1349 'success' => true,
1019 1350 'code' => 200,
1020 1351 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -1043,14 +1374,21 @@
1043 1374 return $result;
1044 1375 }
1045 1376
1046 1377 /**
1047 - * get presigned URL
1378 + * get private URL
1048 1379 * @since 1.0.0
1049 1380 * @return boolean
1050 1381 */
1051 - public function get_presigned_url($key) {
1382 + public function get_private_url($key) {
1052 1383 $result = array();
1384 + if (!$this->s3Client) {
1385 + return array(
1386 + 'success' => false,
1387 + 'code' => 200,
1388 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1389 + );
1390 + }
1053 1391 if (isset($key) && !empty($key)) {
1054 1392 try {
1055 1393 $cmd = $this->s3Client->getCommand('GetObject', [
1056 1394 'Bucket' => $this->bucket_name,
@@ -1056,24 +1394,24 @@
1056 1394 'Bucket' => $this->bucket_name,
1057 1395 'Key' => $key
1058 1396 ]);
1059 1397
1060 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1398 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
1061 1399
1062 1400 $request = $this->s3Client->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1063 1401
1064 - if ($presignedUrl = (string)$request->getUri()) {
1402 + if ($privateUrl = (string)$request->getUri()) {
1065 1403 $result = array(
1066 1404 'success' => true,
1067 1405 'code' => 200,
1068 - 'file_url' => $presignedUrl,
1069 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1406 + 'file_url' => $privateUrl,
1407 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
1070 1408 );
1071 1409 } else {
1072 1410 $result = array(
1073 1411 'success' => false,
1074 1412 'code' => 200,
1075 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1413 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
1076 1414 );
1077 1415 }
1078 1416 } catch (AwsException $e) {
1079 1417 $result = array(
@@ -1094,9 +1432,9 @@
1094 1432
1095 1433 /**
1096 1434 * Generate file URL
1097 1435 */
1098 - private function generate_file_url($key){
1436 + public function generate_file_url($key){
1099 1437 $domain = $this->get_domain();
1100 1438
1101 1439 return apply_filters('wpmcs_generate_s3_file_url',
1102 1440 $domain . '/' . $key,
@@ -1104,12 +1442,26 @@
1104 1442 );
1105 1443 }
1106 1444
1107 1445 /**
1446 + * Is Provider URL
1447 + * @since 1.3.6
1448 + */
1449 + public function is_provider_url($url) {
1450 + $domain = $this->get_domain();
1451 + return (strpos($url, $domain . '/') !== false);
1452 + }
1453 +
1454 + /**
1108 1455 * Get domain URL
1109 1456 */
1110 1457 public function get_domain() {
1111 1458 $region = isset($this->config['region']) ? $this->config['region'] : '';
1112 1459 return "https://{$this->bucket_name}.s3.{$region}.amazonaws.com";
1460 + }
1461 +
1462 + /** Exposes the already-constructed SDK client for StreamWrapper's registration — avoids reconstructing one from credentials. */
1463 + public function get_client() {
1464 + return $this->s3Client;
1113 1465 }
1114 1466
1115 1467 }