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 +415 -175 1.3.10 → 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'])
@@ -88,9 +93,9 @@
88 93 $region = isset($config['region']) ? $config['region'] : '';
89 94 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
90 95 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
91 96
92 - if (!empty($region) && !empty($access_key) && !empty($secret_key)) {
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,
@@ -188,9 +193,9 @@
188 193 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
189 194 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
190 195 $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
191 196
192 - if (!empty($region) && !empty($access_key) && !empty($secret_key) && !empty($bucket_name)) {
197 + if (!Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
193 198 try {
194 199 $s3Client = new S3Client([
195 200 'version' => '2006-03-01',
196 201 'region' => $region,
@@ -243,9 +248,9 @@
243 248 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
244 249 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
245 250 $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
246 251
247 - if (!empty($region) && !empty($access_key) && !empty($secret_key) && !empty($bucket_name)) {
252 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
248 253 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
249 254 }
250 255
251 256 try {
@@ -317,9 +322,9 @@
317 322 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
318 323 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
319 324 $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
320 325
321 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
326 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
322 327 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
323 328 }
324 329
325 330 try {
@@ -335,9 +340,9 @@
335 340 ];
336 341
337 342 $s3Client = new S3Client($s3ClientConfig);
338 343
339 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
344 + $object_key = Utils::get_permission_check_object_key();
340 345
341 346
342 347 // Create a dummy object to check write permission
343 348 $s3Client->putObject([
@@ -373,9 +378,9 @@
373 378 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
374 379 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
375 380 $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
376 381
377 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
382 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
378 383 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
379 384 }
380 385
381 386 try {
@@ -391,9 +396,9 @@
391 396 ];
392 397
393 398 $s3Client = new S3Client($s3ClientConfig);
394 399
395 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
400 + $object_key = Utils::get_permission_check_object_key();
396 401
397 402 // Create a dummy object to check dlete permission
398 403 $s3Client->deleteObject([
399 404 'Bucket' => $bucket_name,
@@ -427,16 +432,15 @@
427 432 'message' => '',
428 433 'lastChecked' => time(),
429 434 ];
430 435
431 - if (empty($this->s3Client) || empty($this->bucket_name)) {
436 + if (Service::has_missing_fields([$this->s3Client, $this->bucket_name])) {
432 437 $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
433 - Utils::set_status('cdnRead', $result);
434 438 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
435 439 }
436 440
437 441 try {
438 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
442 + $object_key = Utils::get_permission_check_object_key();
439 443
440 444 // Check if the object was created successfully
441 445 if (!$this->exists($object_key)) {
442 446 // Create a dummy object to check write permission
@@ -443,30 +447,38 @@
443 447 $this->s3Client->putObject([
444 448 'Bucket' => $this->bucket_name,
445 449 'Key' => $object_key,
446 450 'Body' => 'This is a test object to check permission.',
451 + 'ContentType' => 'text/plain',
452 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
447 453 ]);
448 - }
449 -
454 + }
450 455
456 +
451 457 $url = $this->generate_file_url($object_key);
452 458 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
453 - $headers = @get_headers($cdn_url);
454 - 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) {
455 468 $result['status'] = true;
456 469 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
457 - } else if (strpos($headers[0], '403') !== false) {
470 + } else if ($status_code === 403) {
458 471 $result['status'] = false;
459 - if($this->cdnConfig['service'] == $this->service) {
472 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
460 473 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
461 474 } else {
462 475 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
463 476 }
464 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
465 - } else if (strpos($headers[0], '404') !== false) {
477 + } else if ($status_code === 404) {
466 478 $result['status'] = false;
467 479 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
468 - } else if (strpos($headers[0], '500') !== false) {
480 + } else if ($status_code === 500) {
469 481 $result['status'] = false;
470 482 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
471 483 } else {
472 484 $result['status'] = false;
@@ -471,9 +483,8 @@
471 483 } else {
472 484 $result['status'] = false;
473 485 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
474 486 }
475 - Utils::set_status('cdnRead', $result);
476 487
477 488 $this->deleteSingle($object_key);
478 489 return [
479 490 'message' => $result['message'],
@@ -482,17 +493,14 @@
482 493 'lastChecked' => $result['lastChecked'],
483 494 ];
484 495 } catch (AwsException $ex) {
485 496 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
486 - Utils::set_status('cdnRead', $result);
487 497 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
488 498 } catch (S3Exception $ex) {
489 499 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
490 - Utils::set_status('cdnRead', $result);
491 500 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
492 501 } catch (Exception $ex) {
493 502 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
494 - Utils::set_status('cdnRead', $result);
495 503 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
496 504 }
497 505 }
498 506
@@ -533,9 +541,8 @@
533 541 'Bucket' => $bucket_name,
534 542 ]);
535 543
536 544 $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
537 - $security = [];
538 545 if (
539 546 $publicAccessBlockConfig['BlockPublicAcls'] &&
540 547 $publicAccessBlockConfig['IgnorePublicAcls'] &&
541 548 $publicAccessBlockConfig['BlockPublicPolicy'] &&
@@ -714,10 +721,18 @@
714 721 }
715 722
716 723 /**
717 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.
718 733 */
719 - private function putBucketPolicy($bucket, $s3Client = false) {
734 + private function putBucketPolicy($bucket, $s3Client = false, $private_prefix = '') {
720 735 if($s3Client == false) {
721 736 $s3Client = $this->s3Client;
722 737 }
723 738
@@ -722,38 +737,45 @@
722 737 }
723 738
724 739 if(empty($bucket)) return false;
725 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 +
726 775 $policy = json_encode([
727 - "Version" => "2012-10-17",
728 - "Statement" => [
729 - [
730 - "Effect" => "Allow",
731 - "Principal" => "*",
732 - "Action" => [
733 - "s3:DeleteObjectTagging",
734 - "s3:ListBucketMultipartUploads",
735 - "s3:DeleteObjectVersion",
736 - "s3:ListBucket",
737 - "s3:DeleteObjectVersionTagging",
738 - "s3:GetBucketAcl",
739 - "s3:ListMultipartUploadParts",
740 - "s3:PutObject",
741 - "s3:GetObjectAcl",
742 - "s3:GetObject",
743 - "s3:AbortMultipartUpload",
744 - "s3:DeleteObject",
745 - "s3:GetBucketLocation",
746 - "s3:PutObjectAcl",
747 - "s3:putBucketOwnershipControls",
748 - "s3:putBucketPolicy"
749 - ],
750 - "Resource" => [
751 - "arn:aws:s3:::$bucket/*",
752 - "arn:aws:s3:::$bucket"
753 - ]
754 - ]
755 - ]
776 + "Version" => "2012-10-17",
777 + "Statement" => [$statement]
756 778 ]);
757 779
758 780 try {
759 781 // Add bucket policy
@@ -769,8 +791,25 @@
769 791 }
770 792 }
771 793
772 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 + /**
773 812 * Add Bucket Ownership
774 813 */
775 814 private function changeBucketOwnership($bucket, $s3Client = false, $ownership = 'BucketOwnerPreferred') {
776 815 if($s3Client == false) {
@@ -840,9 +879,9 @@
840 879
841 880 // If we reach here, the credentials are valid
842 881 return true;
843 882 } catch (AwsException $ex) {
844 - $code = $e->getAwsErrorCode();
883 + $code = $ex->getAwsErrorCode();
845 884
846 885 $validErrors = [
847 886 'AccessDenied',
848 887 'NoSuchBucket',
@@ -873,8 +912,9 @@
873 912 *
874 913 */
875 914 public function toPrivate($key) {
876 915 if(!$key) return false;
916 + if(!$this->s3Client) return false;
877 917 try {
878 918 $this->s3Client->putObjectAcl([
879 919 'Bucket' => $this->bucket_name,
880 920 'Key' => $key,
@@ -883,9 +923,8 @@
883 923 return true;
884 924 } catch (AwsException $ex) {
885 925 return false;
886 926 }
887 - return false;
888 927 }
889 928
890 929
891 930
@@ -891,23 +930,23 @@
891 930
892 931 /**
893 932 * Make Object Public
894 933 * @since 1.0.0
895 - *
934 + *
896 935 */
897 936 public function toPublic($key) {
898 937 if(!$key) return false;
938 + if(!$this->s3Client) return false;
899 939 try {
900 940 $this->s3Client->putObjectAcl([
901 941 'Bucket' => $this->bucket_name,
902 942 'Key' => $key,
903 943 'ACL' => 'public-read'
904 - ]);
944 + ]);
905 945 return true;
906 946 } catch (AwsException $ex) {
907 947 return false;
908 948 }
909 - return false;
910 949 }
911 950
912 951
913 952
@@ -922,8 +961,9 @@
922 961 $client = $client ?? $this->s3Client;
923 962 if($client->doesObjectExistV2( $bucket_name, $key)) {
924 963 return true;
925 964 }
965 + return false;
926 966 } catch (AwsException $ex) {
927 967 return false;
928 968 } catch (S3Exception $ex) {
929 969 return false;
@@ -932,14 +972,65 @@
932 972 }
933 973 }
934 974
935 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 + /**
936 1028 * Upload Single
937 1029 * @since 1.0.0
938 1030 * @return boolean
939 1031 */
940 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
941 - $result = array();
1032 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
942 1033 if (
943 1034 isset($absolute_source_path) && !empty($absolute_source_path) &&
944 1035 isset($relative_source_path) && !empty($relative_source_path)
945 1036 ) {
@@ -944,95 +1035,125 @@
944 1035 isset($relative_source_path) && !empty($relative_source_path)
945 1036 ) {
946 1037 $file_name = wp_basename( $relative_source_path );
947 1038 if ($file_name) {
948 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
949 -
950 - // Decide Multipart upload or normal put object
951 - if (filesize($absolute_source_path) <= Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE')) {
952 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
953 - try {
954 - $handle = fopen($absolute_source_path, 'rb');
955 -
956 - $upload = $this->s3Client->putObject([
957 - 'Bucket' => $this->bucket_name,
958 - 'Key' => $upload_path,
959 - 'Body' => $handle,
960 - ]);
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 + }
961 1064
962 - if (is_resource($handle)) {
963 - fclose($handle);
964 - }
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 + }
965 1072
966 - $result = array(
967 - 'success' => true,
968 - 'code' => 200,
969 - 'file_url' => $this->generate_file_url($upload_path),
970 - 'key' => $upload_path,
971 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
972 - );
973 - } catch (AwsException $e) {
974 - $result = array(
975 - 'success' => false,
976 - 'code' => 200,
977 - 'message' => $e->getMessage()
978 - );
979 - }
980 - } else {
981 - $multiUploader = new MultipartUploader($this->s3Client, $absolute_source_path, [
982 - 'bucket' => $this->bucket_name,
983 - 'key' => $upload_path,
984 - ]);
985 -
986 - try {
987 - do {
988 - try {
989 - $uploaded = $multiUploader->upload();
990 - } catch (MultipartUploadException $e) {
991 - $multiUploader = new MultipartUploader($this->s3Client, $absolute_source_path, [
992 - 'state' => $e->getState(),
993 - ]);
994 - }
995 - } while (!isset($uploaded));
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 + }
996 1098
997 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
998 - $result = array(
999 - 'success' => true,
1000 - 'code' => 200,
1001 - 'file_url' => $this->generate_file_url($upload_path),
1002 - 'key' => $upload_path,
1003 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
1004 - );
1005 - } else {
1006 - $result = array(
1007 - 'success' => false,
1008 - 'code' => 200,
1009 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
1010 - );
1011 - }
1012 - } catch (MultipartUploadException $e) {
1013 - $result = array(
1014 - 'success' => false,
1015 - 'code' => 200,
1016 - 'message' => $e->getMessage()
1017 - );
1018 - }
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 + ];
1019 1140 }
1020 - } else {
1021 - $result = array(
1141 + $options = ['state' => $e->getState()];
1142 + } catch (AwsException $e) {
1143 + return [
1022 1144 'success' => false,
1023 1145 'code' => 200,
1024 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
1025 - );
1146 + 'message' => $e->getMessage()
1147 + ];
1148 + } catch (Exception $e) {
1149 + return [
1150 + 'success' => false,
1151 + 'code' => 200,
1152 + 'message' => $e->getMessage()
1153 + ];
1026 1154 }
1027 - } else {
1028 - $result = array(
1029 - 'success' => false,
1030 - 'code' => 200,
1031 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1032 - );
1033 1155 }
1034 - return $result;
1035 1156 }
1036 1157
1037 1158 /**
1038 1159 * Save object to server
@@ -1038,8 +1159,9 @@
1038 1159 * Save object to server
1039 1160 * @since 1.0.0
1040 1161 */
1041 1162 public function object_to_server($key, $save_path) {
1163 + if(!$this->s3Client) return false;
1042 1164 try {
1043 1165 $getObject = $this->s3Client->GetObject([
1044 1166 'Bucket' => $this->bucket_name,
1045 1167 'Key' => $key,
@@ -1053,41 +1175,114 @@
1053 1175 }
1054 1176 return false;
1055 1177 }
1056 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 + }
1057 1196
1058 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 + /**
1059 1257 * Copy object to new path
1060 1258 * @since 1.3.4
1061 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.
1062 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 + }
1063 1273 try {
1064 - // Step 1: Verify object exists at old location
1065 - if (!$this->exists($key)) {
1066 - return [
1067 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
1068 - 'code' => 200,
1069 - 'success' => false
1070 - ];
1071 - }
1072 - // Step 2: Copy object
1073 - if (!$this->exists($new_path)) {
1074 - $this->s3Client->copyObject([
1075 - 'Bucket' => $this->bucket_name,
1076 - 'CopySource' => "{$this->bucket_name}/{$key}",
1077 - 'Key' => $new_path,
1078 - 'MetadataDirective' => 'COPY',
1079 - ]);
1080 - }
1081 -
1082 - // Step 3: Verify object exists at new location
1083 - if ($this->exists($new_path)) {
1084 - return [
1085 - 'success' => true,
1086 - 'code' => 200,
1087 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1088 - ];
1089 - }
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 + ];
1090 1285 } catch (AwsException $e) {
1091 1286 return [
1092 1287 'success' => false,
1093 1288 'code' => 200,
@@ -1093,16 +1288,42 @@
1093 1288 'code' => 200,
1094 1289 'message' => $e->getMessage()
1095 1290 ];
1096 1291 }
1097 - return [
1098 - 'success' => false,
1099 - 'code' => 200,
1100 - 'message' => esc_html__('Something went wrong', 'media-cloud-sync')
1101 - ];
1102 1292 }
1103 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 + }
1104 1324
1325 +
1105 1326 /**
1106 1327 * Delete Single
1107 1328 * @since 1.0.0
1108 1329 * @return boolean
@@ -1108,8 +1329,15 @@
1108 1329 * @return boolean
1109 1330 */
1110 1331 public function deleteSingle($key) {
1111 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 + }
1112 1340 if (isset($key) && !empty($key)) {
1113 1341 try {
1114 1342 $this->s3Client->deleteObject([
1115 1343 'Bucket' => $this->bucket_name,
@@ -1152,8 +1380,15 @@
1152 1380 * @return boolean
1153 1381 */
1154 1382 public function get_private_url($key) {
1155 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 + }
1156 1391 if (isset($key) && !empty($key)) {
1157 1392 try {
1158 1393 $cmd = $this->s3Client->getCommand('GetObject', [
1159 1394 'Bucket' => $this->bucket_name,
@@ -1221,7 +1456,12 @@
1221 1456 */
1222 1457 public function get_domain() {
1223 1458 $region = isset($this->config['region']) ? $this->config['region'] : '';
1224 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;
1225 1465 }
1226 1466
1227 1467 }