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 -170 1.3.11 → 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,15 +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 438 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
434 439 }
435 440
436 441 try {
437 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
442 + $object_key = Utils::get_permission_check_object_key();
438 443
439 444 // Check if the object was created successfully
440 445 if (!$this->exists($object_key)) {
441 446 // Create a dummy object to check write permission
@@ -442,30 +447,38 @@
442 447 $this->s3Client->putObject([
443 448 'Bucket' => $this->bucket_name,
444 449 'Key' => $object_key,
445 450 'Body' => 'This is a test object to check permission.',
451 + 'ContentType' => 'text/plain',
452 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
446 453 ]);
447 - }
448 -
454 + }
449 455
456 +
450 457 $url = $this->generate_file_url($object_key);
451 458 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
452 - $headers = @get_headers($cdn_url);
453 - 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) {
454 468 $result['status'] = true;
455 469 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
456 - } else if (strpos($headers[0], '403') !== false) {
470 + } else if ($status_code === 403) {
457 471 $result['status'] = false;
458 - if($this->cdnConfig['service'] == $this->service) {
472 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
459 473 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
460 474 } else {
461 475 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
462 476 }
463 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
464 - } else if (strpos($headers[0], '404') !== false) {
477 + } else if ($status_code === 404) {
465 478 $result['status'] = false;
466 479 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
467 - } else if (strpos($headers[0], '500') !== false) {
480 + } else if ($status_code === 500) {
468 481 $result['status'] = false;
469 482 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
470 483 } else {
471 484 $result['status'] = false;
@@ -528,9 +541,8 @@
528 541 'Bucket' => $bucket_name,
529 542 ]);
530 543
531 544 $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
532 - $security = [];
533 545 if (
534 546 $publicAccessBlockConfig['BlockPublicAcls'] &&
535 547 $publicAccessBlockConfig['IgnorePublicAcls'] &&
536 548 $publicAccessBlockConfig['BlockPublicPolicy'] &&
@@ -709,10 +721,18 @@
709 721 }
710 722
711 723 /**
712 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.
713 733 */
714 - private function putBucketPolicy($bucket, $s3Client = false) {
734 + private function putBucketPolicy($bucket, $s3Client = false, $private_prefix = '') {
715 735 if($s3Client == false) {
716 736 $s3Client = $this->s3Client;
717 737 }
718 738
@@ -717,38 +737,45 @@
717 737 }
718 738
719 739 if(empty($bucket)) return false;
720 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 +
721 775 $policy = json_encode([
722 - "Version" => "2012-10-17",
723 - "Statement" => [
724 - [
725 - "Effect" => "Allow",
726 - "Principal" => "*",
727 - "Action" => [
728 - "s3:DeleteObjectTagging",
729 - "s3:ListBucketMultipartUploads",
730 - "s3:DeleteObjectVersion",
731 - "s3:ListBucket",
732 - "s3:DeleteObjectVersionTagging",
733 - "s3:GetBucketAcl",
734 - "s3:ListMultipartUploadParts",
735 - "s3:PutObject",
736 - "s3:GetObjectAcl",
737 - "s3:GetObject",
738 - "s3:AbortMultipartUpload",
739 - "s3:DeleteObject",
740 - "s3:GetBucketLocation",
741 - "s3:PutObjectAcl",
742 - "s3:putBucketOwnershipControls",
743 - "s3:putBucketPolicy"
744 - ],
745 - "Resource" => [
746 - "arn:aws:s3:::$bucket/*",
747 - "arn:aws:s3:::$bucket"
748 - ]
749 - ]
750 - ]
776 + "Version" => "2012-10-17",
777 + "Statement" => [$statement]
751 778 ]);
752 779
753 780 try {
754 781 // Add bucket policy
@@ -764,8 +791,25 @@
764 791 }
765 792 }
766 793
767 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 + /**
768 812 * Add Bucket Ownership
769 813 */
770 814 private function changeBucketOwnership($bucket, $s3Client = false, $ownership = 'BucketOwnerPreferred') {
771 815 if($s3Client == false) {
@@ -835,9 +879,9 @@
835 879
836 880 // If we reach here, the credentials are valid
837 881 return true;
838 882 } catch (AwsException $ex) {
839 - $code = $e->getAwsErrorCode();
883 + $code = $ex->getAwsErrorCode();
840 884
841 885 $validErrors = [
842 886 'AccessDenied',
843 887 'NoSuchBucket',
@@ -868,8 +912,9 @@
868 912 *
869 913 */
870 914 public function toPrivate($key) {
871 915 if(!$key) return false;
916 + if(!$this->s3Client) return false;
872 917 try {
873 918 $this->s3Client->putObjectAcl([
874 919 'Bucket' => $this->bucket_name,
875 920 'Key' => $key,
@@ -878,9 +923,8 @@
878 923 return true;
879 924 } catch (AwsException $ex) {
880 925 return false;
881 926 }
882 - return false;
883 927 }
884 928
885 929
886 930
@@ -886,23 +930,23 @@
886 930
887 931 /**
888 932 * Make Object Public
889 933 * @since 1.0.0
890 - *
934 + *
891 935 */
892 936 public function toPublic($key) {
893 937 if(!$key) return false;
938 + if(!$this->s3Client) return false;
894 939 try {
895 940 $this->s3Client->putObjectAcl([
896 941 'Bucket' => $this->bucket_name,
897 942 'Key' => $key,
898 943 'ACL' => 'public-read'
899 - ]);
944 + ]);
900 945 return true;
901 946 } catch (AwsException $ex) {
902 947 return false;
903 948 }
904 - return false;
905 949 }
906 950
907 951
908 952
@@ -917,8 +961,9 @@
917 961 $client = $client ?? $this->s3Client;
918 962 if($client->doesObjectExistV2( $bucket_name, $key)) {
919 963 return true;
920 964 }
965 + return false;
921 966 } catch (AwsException $ex) {
922 967 return false;
923 968 } catch (S3Exception $ex) {
924 969 return false;
@@ -927,14 +972,65 @@
927 972 }
928 973 }
929 974
930 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 + /**
931 1028 * Upload Single
932 1029 * @since 1.0.0
933 1030 * @return boolean
934 1031 */
935 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
936 - $result = array();
1032 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
937 1033 if (
938 1034 isset($absolute_source_path) && !empty($absolute_source_path) &&
939 1035 isset($relative_source_path) && !empty($relative_source_path)
940 1036 ) {
@@ -939,95 +1035,125 @@
939 1035 isset($relative_source_path) && !empty($relative_source_path)
940 1036 ) {
941 1037 $file_name = wp_basename( $relative_source_path );
942 1038 if ($file_name) {
943 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
944 -
945 - // Decide Multipart upload or normal put object
946 - if (filesize($absolute_source_path) <= Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE')) {
947 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
948 - try {
949 - $handle = fopen($absolute_source_path, 'rb');
950 -
951 - $upload = $this->s3Client->putObject([
952 - 'Bucket' => $this->bucket_name,
953 - 'Key' => $upload_path,
954 - 'Body' => $handle,
955 - ]);
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 + }
956 1064
957 - if (is_resource($handle)) {
958 - fclose($handle);
959 - }
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 + }
960 1072
961 - $result = array(
962 - 'success' => true,
963 - 'code' => 200,
964 - 'file_url' => $this->generate_file_url($upload_path),
965 - 'key' => $upload_path,
966 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
967 - );
968 - } catch (AwsException $e) {
969 - $result = array(
970 - 'success' => false,
971 - 'code' => 200,
972 - 'message' => $e->getMessage()
973 - );
974 - }
975 - } else {
976 - $multiUploader = new MultipartUploader($this->s3Client, $absolute_source_path, [
977 - 'bucket' => $this->bucket_name,
978 - 'key' => $upload_path,
979 - ]);
980 -
981 - try {
982 - do {
983 - try {
984 - $uploaded = $multiUploader->upload();
985 - } catch (MultipartUploadException $e) {
986 - $multiUploader = new MultipartUploader($this->s3Client, $absolute_source_path, [
987 - 'state' => $e->getState(),
988 - ]);
989 - }
990 - } 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 + }
991 1098
992 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
993 - $result = array(
994 - 'success' => true,
995 - 'code' => 200,
996 - 'file_url' => $this->generate_file_url($upload_path),
997 - 'key' => $upload_path,
998 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
999 - );
1000 - } else {
1001 - $result = array(
1002 - 'success' => false,
1003 - 'code' => 200,
1004 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
1005 - );
1006 - }
1007 - } catch (MultipartUploadException $e) {
1008 - $result = array(
1009 - 'success' => false,
1010 - 'code' => 200,
1011 - 'message' => $e->getMessage()
1012 - );
1013 - }
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 + ];
1014 1140 }
1015 - } else {
1016 - $result = array(
1141 + $options = ['state' => $e->getState()];
1142 + } catch (AwsException $e) {
1143 + return [
1017 1144 'success' => false,
1018 1145 'code' => 200,
1019 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
1020 - );
1146 + 'message' => $e->getMessage()
1147 + ];
1148 + } catch (Exception $e) {
1149 + return [
1150 + 'success' => false,
1151 + 'code' => 200,
1152 + 'message' => $e->getMessage()
1153 + ];
1021 1154 }
1022 - } else {
1023 - $result = array(
1024 - 'success' => false,
1025 - 'code' => 200,
1026 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1027 - );
1028 1155 }
1029 - return $result;
1030 1156 }
1031 1157
1032 1158 /**
1033 1159 * Save object to server
@@ -1033,8 +1159,9 @@
1033 1159 * Save object to server
1034 1160 * @since 1.0.0
1035 1161 */
1036 1162 public function object_to_server($key, $save_path) {
1163 + if(!$this->s3Client) return false;
1037 1164 try {
1038 1165 $getObject = $this->s3Client->GetObject([
1039 1166 'Bucket' => $this->bucket_name,
1040 1167 'Key' => $key,
@@ -1048,41 +1175,114 @@
1048 1175 }
1049 1176 return false;
1050 1177 }
1051 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 + }
1052 1196
1053 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 + /**
1054 1257 * Copy object to new path
1055 1258 * @since 1.3.4
1056 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.
1057 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 + }
1058 1273 try {
1059 - // Step 1: Verify object exists at old location
1060 - if (!$this->exists($key)) {
1061 - return [
1062 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
1063 - 'code' => 200,
1064 - 'success' => false
1065 - ];
1066 - }
1067 - // Step 2: Copy object
1068 - if (!$this->exists($new_path)) {
1069 - $this->s3Client->copyObject([
1070 - 'Bucket' => $this->bucket_name,
1071 - 'CopySource' => "{$this->bucket_name}/{$key}",
1072 - 'Key' => $new_path,
1073 - 'MetadataDirective' => 'COPY',
1074 - ]);
1075 - }
1076 -
1077 - // Step 3: Verify object exists at new location
1078 - if ($this->exists($new_path)) {
1079 - return [
1080 - 'success' => true,
1081 - 'code' => 200,
1082 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1083 - ];
1084 - }
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 + ];
1085 1285 } catch (AwsException $e) {
1086 1286 return [
1087 1287 'success' => false,
1088 1288 'code' => 200,
@@ -1088,16 +1288,42 @@
1088 1288 'code' => 200,
1089 1289 'message' => $e->getMessage()
1090 1290 ];
1091 1291 }
1092 - return [
1093 - 'success' => false,
1094 - 'code' => 200,
1095 - 'message' => esc_html__('Something went wrong', 'media-cloud-sync')
1096 - ];
1097 1292 }
1098 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 + }
1099 1324
1325 +
1100 1326 /**
1101 1327 * Delete Single
1102 1328 * @since 1.0.0
1103 1329 * @return boolean
@@ -1103,8 +1329,15 @@
1103 1329 * @return boolean
1104 1330 */
1105 1331 public function deleteSingle($key) {
1106 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 + }
1107 1340 if (isset($key) && !empty($key)) {
1108 1341 try {
1109 1342 $this->s3Client->deleteObject([
1110 1343 'Bucket' => $this->bucket_name,
@@ -1147,8 +1380,15 @@
1147 1380 * @return boolean
1148 1381 */
1149 1382 public function get_private_url($key) {
1150 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 + }
1151 1391 if (isset($key) && !empty($key)) {
1152 1392 try {
1153 1393 $cmd = $this->s3Client->getCommand('GetObject', [
1154 1394 'Bucket' => $this->bucket_name,
@@ -1216,7 +1456,12 @@
1216 1456 */
1217 1457 public function get_domain() {
1218 1458 $region = isset($this->config['region']) ? $this->config['region'] : '';
1219 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;
1220 1465 }
1221 1466
1222 1467 }