PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
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 1.2.9 All 35 releases
← All changes | includes/base/services/s3.php +523 -178 1.2.111.4.1 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,
@@ -181,15 +186,16 @@
181 186 * Verify Bucket Exist
182 187 * @since 1.0.0
183 188 * @return boolean
184 189 */
185 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration=false){
186 - if (
187 - isset($region) && !empty($region) &&
188 - isset($access_key) && !empty($access_key) &&
189 - isset($secret_key) && !empty($secret_key) &&
190 - isset($bucket_name) && !empty($bucket_name)
191 - ) {
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])) {
192 198 try {
193 199 $s3Client = new S3Client([
194 200 'version' => '2006-03-01',
195 201 'region' => $region,
@@ -235,10 +241,16 @@
235 241 * Create Bucket
236 242 * @since 1.0.0
237 243 * @return boolean
238 244 */
239 - public function createBucket($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
240 - 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])) {
241 253 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
242 254 }
243 255
244 256 try {
@@ -303,10 +315,16 @@
303 315 /**
304 316 * Check Bucket Write Permission
305 317 * @since 1.0.0
306 318 */
307 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
308 - 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])) {
309 327 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
310 328 }
311 329
312 330 try {
@@ -322,9 +340,9 @@
322 340 ];
323 341
324 342 $s3Client = new S3Client($s3ClientConfig);
325 343
326 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
344 + $object_key = Utils::get_permission_check_object_key();
327 345
328 346
329 347 // Create a dummy object to check write permission
330 348 $s3Client->putObject([
@@ -332,9 +350,9 @@
332 350 'Key' => $object_key,
333 351 'Body' => 'This is a test object to check write permission.',
334 352 ]);
335 353 // Check if the object was created successfully
336 - if ($s3Client->doesObjectExist($bucket_name, $object_key)) {
354 + if ($this->exists($object_key, $bucket_name, $s3Client)) {
337 355 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
338 356 } else {
339 357 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
340 358 }
@@ -353,10 +371,16 @@
353 371 /**
354 372 * Check Bucket Delete Permission
355 373 * @since 1.0.0
356 374 */
357 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
358 - 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])) {
359 383 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
360 384 }
361 385
362 386 try {
@@ -372,9 +396,9 @@
372 396 ];
373 397
374 398 $s3Client = new S3Client($s3ClientConfig);
375 399
376 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
400 + $object_key = Utils::get_permission_check_object_key();
377 401
378 402 // Create a dummy object to check dlete permission
379 403 $s3Client->deleteObject([
380 404 'Bucket' => $bucket_name,
@@ -381,9 +405,9 @@
381 405 'Key' => $object_key,
382 406 ]);
383 407
384 408 // Check if the object was created successfully
385 - if (!$s3Client->doesObjectExist($bucket_name, $object_key)) {
409 + if (!$this->exists($object_key, $bucket_name, $s3Client)) {
386 410 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
387 411 } else {
388 412 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
389 413 }
@@ -402,49 +426,59 @@
402 426 * Check Bucket Read Permission
403 427 * @since 1.2.4
404 428 */
405 429 public function verifyObjectReadPermission() {
406 - if (empty($this->s3Client) || empty($this->bucket_name)) {
407 - 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()];
408 439 }
409 440
410 441 try {
411 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
442 + $object_key = Utils::get_permission_check_object_key();
412 443
413 444 // Check if the object was created successfully
414 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $object_key)) {
445 + if (!$this->exists($object_key)) {
415 446 // Create a dummy object to check write permission
416 447 $this->s3Client->putObject([
417 448 'Bucket' => $this->bucket_name,
418 449 'Key' => $object_key,
419 450 'Body' => 'This is a test object to check permission.',
451 + 'ContentType' => 'text/plain',
452 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
420 453 ]);
421 - }
422 -
454 + }
423 455
456 +
424 457 $url = $this->generate_file_url($object_key);
425 458 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
426 - $headers = @get_headers($cdn_url);
427 - $result = [
428 - 'status' => false,
429 - 'message' => '',
430 - 'lastChecked' => time(),
431 - ];
432 - 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) {
433 468 $result['status'] = true;
434 469 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
435 - } else if (strpos($headers[0], '403') !== false) {
470 + } else if ($status_code === 403) {
436 471 $result['status'] = false;
437 - if($this->cdnConfig['service'] == $this->service) {
472 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
438 473 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
439 474 } else {
440 475 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
441 476 }
442 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
443 - } else if (strpos($headers[0], '404') !== false) {
477 + } else if ($status_code === 404) {
444 478 $result['status'] = false;
445 479 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
446 - } else if (strpos($headers[0], '500') !== false) {
480 + } else if ($status_code === 500) {
447 481 $result['status'] = false;
448 482 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
449 483 } else {
450 484 $result['status'] = false;
@@ -449,9 +483,8 @@
449 483 } else {
450 484 $result['status'] = false;
451 485 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
452 486 }
453 - Utils::set_status('cdnRead', $result);
454 487
455 488 $this->deleteSingle($object_key);
456 489 return [
457 490 'message' => $result['message'],
@@ -459,13 +492,16 @@
459 492 'success' => $result['status'],
460 493 'lastChecked' => $result['lastChecked'],
461 494 ];
462 495 } catch (AwsException $ex) {
463 - 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()];
464 498 } catch (S3Exception $ex) {
465 - 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()];
466 501 } catch (Exception $ex) {
467 - 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()];
468 504 }
469 505 }
470 506
471 507
@@ -471,9 +507,15 @@
471 507
472 508 /**
473 509 * get Bucket Security Settings
474 510 */
475 - 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 +
476 518 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
477 519 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
478 520 }
479 521
@@ -499,9 +541,8 @@
499 541 'Bucket' => $bucket_name,
500 542 ]);
501 543
502 544 $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
503 - $security = [];
504 545 if (
505 546 $publicAccessBlockConfig['BlockPublicAcls'] &&
506 547 $publicAccessBlockConfig['IgnorePublicAcls'] &&
507 548 $publicAccessBlockConfig['BlockPublicPolicy'] &&
@@ -549,9 +590,15 @@
549 590 /**
550 591 * Change Bucket Public Access
551 592 */
552 593
553 - 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 +
554 601 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
555 602 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
556 603 }
557 604
@@ -593,9 +640,15 @@
593 640 /**
594 641 * Change Bucket Ownership
595 642 */
596 643
597 - 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 +
598 651 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
599 652 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
600 653 }
601 654
@@ -668,10 +721,18 @@
668 721 }
669 722
670 723 /**
671 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.
672 733 */
673 - private function putBucketPolicy($bucket, $s3Client = false) {
734 + private function putBucketPolicy($bucket, $s3Client = false, $private_prefix = '') {
674 735 if($s3Client == false) {
675 736 $s3Client = $this->s3Client;
676 737 }
677 738
@@ -676,38 +737,45 @@
676 737 }
677 738
678 739 if(empty($bucket)) return false;
679 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 +
680 775 $policy = json_encode([
681 - "Version" => "2012-10-17",
682 - "Statement" => [
683 - [
684 - "Effect" => "Allow",
685 - "Principal" => "*",
686 - "Action" => [
687 - "s3:DeleteObjectTagging",
688 - "s3:ListBucketMultipartUploads",
689 - "s3:DeleteObjectVersion",
690 - "s3:ListBucket",
691 - "s3:DeleteObjectVersionTagging",
692 - "s3:GetBucketAcl",
693 - "s3:ListMultipartUploadParts",
694 - "s3:PutObject",
695 - "s3:GetObjectAcl",
696 - "s3:GetObject",
697 - "s3:AbortMultipartUpload",
698 - "s3:DeleteObject",
699 - "s3:GetBucketLocation",
700 - "s3:PutObjectAcl",
701 - "s3:putBucketOwnershipControls",
702 - "s3:putBucketPolicy"
703 - ],
704 - "Resource" => [
705 - "arn:aws:s3:::$bucket/*",
706 - "arn:aws:s3:::$bucket"
707 - ]
708 - ]
709 - ]
776 + "Version" => "2012-10-17",
777 + "Statement" => [$statement]
710 778 ]);
711 779
712 780 try {
713 781 // Add bucket policy
@@ -723,8 +791,25 @@
723 791 }
724 792 }
725 793
726 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 + /**
727 812 * Add Bucket Ownership
728 813 */
729 814 private function changeBucketOwnership($bucket, $s3Client = false, $ownership = 'BucketOwnerPreferred') {
730 815 if($s3Client == false) {
@@ -794,9 +879,9 @@
794 879
795 880 // If we reach here, the credentials are valid
796 881 return true;
797 882 } catch (AwsException $ex) {
798 - $code = $e->getAwsErrorCode();
883 + $code = $ex->getAwsErrorCode();
799 884
800 885 $validErrors = [
801 886 'AccessDenied',
802 887 'NoSuchBucket',
@@ -827,8 +912,9 @@
827 912 *
828 913 */
829 914 public function toPrivate($key) {
830 915 if(!$key) return false;
916 + if(!$this->s3Client) return false;
831 917 try {
832 918 $this->s3Client->putObjectAcl([
833 919 'Bucket' => $this->bucket_name,
834 920 'Key' => $key,
@@ -837,9 +923,8 @@
837 923 return true;
838 924 } catch (AwsException $ex) {
839 925 return false;
840 926 }
841 - return false;
842 927 }
843 928
844 929
845 930
@@ -845,23 +930,23 @@
845 930
846 931 /**
847 932 * Make Object Public
848 933 * @since 1.0.0
849 - *
934 + *
850 935 */
851 936 public function toPublic($key) {
852 937 if(!$key) return false;
938 + if(!$this->s3Client) return false;
853 939 try {
854 940 $this->s3Client->putObjectAcl([
855 941 'Bucket' => $this->bucket_name,
856 942 'Key' => $key,
857 943 'ACL' => 'public-read'
858 - ]);
944 + ]);
859 945 return true;
860 946 } catch (AwsException $ex) {
861 947 return false;
862 948 }
863 - return false;
864 949 }
865 950
866 951
867 952
@@ -868,14 +953,17 @@
868 953 /**
869 954 * Check the object exist
870 955 * @since 1.1.8
871 956 */
872 - public function exists($key) {
957 + public function exists($key, $bucket_name = '', $client = null) {
873 958 if(!$key) return false;
874 959 try {
875 - 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)) {
876 963 return true;
877 964 }
965 + return false;
878 966 } catch (AwsException $ex) {
879 967 return false;
880 968 } catch (S3Exception $ex) {
881 969 return false;
@@ -884,101 +972,188 @@
884 972 }
885 973 }
886 974
887 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 + /**
888 1028 * Upload Single
889 1029 * @since 1.0.0
890 1030 * @return boolean
891 1031 */
892 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
893 - $result = array();
1032 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
894 1033 if (
895 - isset($media_absolute_path) && !empty($media_absolute_path) &&
896 - isset($media_path) && !empty($media_path)
1034 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1035 + isset($relative_source_path) && !empty($relative_source_path)
897 1036 ) {
898 - $file_name = wp_basename( $media_path );
1037 + $file_name = wp_basename( $relative_source_path );
899 1038 if ($file_name) {
900 - $upload_path = Utils::generate_object_key($media_path, $prefix);
901 -
902 - // Decide Multipart upload or normal put object
903 - if (filesize($media_absolute_path) <= Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE')) {
904 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
905 - try {
906 - $upload = $this->s3Client->putObject([
907 - 'Bucket' => $this->bucket_name,
908 - 'Key' => $upload_path,
909 - 'Body' => fopen($media_absolute_path, 'r'),
910 - ]);
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 + }
911 1064
912 - $result = array(
913 - 'success' => true,
914 - 'code' => 200,
915 - 'file_url' => $this->generate_file_url($upload_path),
916 - 'key' => $upload_path,
917 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
918 - );
919 - } catch (AwsException $e) {
920 - $result = array(
921 - 'success' => false,
922 - 'code' => 200,
923 - 'message' => $e->getMessage()
924 - );
925 - }
926 - } else {
927 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
928 - 'bucket' => $this->bucket_name,
929 - 'key' => $upload_path,
930 - ]);
931 -
932 - try {
933 - do {
934 - try {
935 - $uploaded = $multiUploader->upload();
936 - } catch (MultipartUploadException $e) {
937 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
938 - 'state' => $e->getState(),
939 - ]);
940 - }
941 - } 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 + }
942 1072
943 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
944 - $result = array(
945 - 'success' => true,
946 - 'code' => 200,
947 - 'file_url' => $this->generate_file_url($upload_path),
948 - 'key' => $upload_path,
949 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
950 - );
951 - } else {
952 - $result = array(
953 - 'success' => false,
954 - 'code' => 200,
955 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
956 - );
957 - }
958 - } catch (MultipartUploadException $e) {
959 - $result = array(
960 - 'success' => false,
961 - 'code' => 200,
962 - 'message' => $e->getMessage()
963 - );
964 - }
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 + ];
965 1140 }
966 - } else {
967 - $result = array(
1141 + $options = ['state' => $e->getState()];
1142 + } catch (AwsException $e) {
1143 + return [
968 1144 'success' => false,
969 1145 'code' => 200,
970 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
971 - );
1146 + 'message' => $e->getMessage()
1147 + ];
1148 + } catch (Exception $e) {
1149 + return [
1150 + 'success' => false,
1151 + 'code' => 200,
1152 + 'message' => $e->getMessage()
1153 + ];
972 1154 }
973 - } else {
974 - $result = array(
975 - 'success' => false,
976 - 'code' => 200,
977 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
978 - );
979 1155 }
980 - return $result;
981 1156 }
982 1157
983 1158 /**
984 1159 * Save object to server
@@ -984,8 +1159,9 @@
984 1159 * Save object to server
985 1160 * @since 1.0.0
986 1161 */
987 1162 public function object_to_server($key, $save_path) {
1163 + if(!$this->s3Client) return false;
988 1164 try {
989 1165 $getObject = $this->s3Client->GetObject([
990 1166 'Bucket' => $this->bucket_name,
991 1167 'Key' => $key,
@@ -999,10 +1175,156 @@
999 1175 }
1000 1176 return false;
1001 1177 }
1002 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 + }
1003 1196
1004 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 + /**
1005 1327 * Delete Single
1006 1328 * @since 1.0.0
1007 1329 * @return boolean
1008 1330 */
@@ -1007,8 +1329,15 @@
1007 1329 * @return boolean
1008 1330 */
1009 1331 public function deleteSingle($key) {
1010 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 + }
1011 1340 if (isset($key) && !empty($key)) {
1012 1341 try {
1013 1342 $this->s3Client->deleteObject([
1014 1343 'Bucket' => $this->bucket_name,
@@ -1014,9 +1343,9 @@
1014 1343 'Bucket' => $this->bucket_name,
1015 1344 'Key' => $key
1016 1345 ]);
1017 1346
1018 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $key)) {
1347 + if (!$this->exists($key)) {
1019 1348 $result = array(
1020 1349 'success' => true,
1021 1350 'code' => 200,
1022 1351 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -1045,14 +1374,21 @@
1045 1374 return $result;
1046 1375 }
1047 1376
1048 1377 /**
1049 - * get presigned URL
1378 + * get private URL
1050 1379 * @since 1.0.0
1051 1380 * @return boolean
1052 1381 */
1053 - public function get_presigned_url($key) {
1382 + public function get_private_url($key) {
1054 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 + }
1055 1391 if (isset($key) && !empty($key)) {
1056 1392 try {
1057 1393 $cmd = $this->s3Client->getCommand('GetObject', [
1058 1394 'Bucket' => $this->bucket_name,
@@ -1058,24 +1394,24 @@
1058 1394 'Bucket' => $this->bucket_name,
1059 1395 'Key' => $key
1060 1396 ]);
1061 1397
1062 - $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;
1063 1399
1064 1400 $request = $this->s3Client->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1065 1401
1066 - if ($presignedUrl = (string)$request->getUri()) {
1402 + if ($privateUrl = (string)$request->getUri()) {
1067 1403 $result = array(
1068 1404 'success' => true,
1069 1405 'code' => 200,
1070 - 'file_url' => $presignedUrl,
1071 - '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')
1072 1408 );
1073 1409 } else {
1074 1410 $result = array(
1075 1411 'success' => false,
1076 1412 'code' => 200,
1077 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1413 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
1078 1414 );
1079 1415 }
1080 1416 } catch (AwsException $e) {
1081 1417 $result = array(
@@ -1096,9 +1432,9 @@
1096 1432
1097 1433 /**
1098 1434 * Generate file URL
1099 1435 */
1100 - private function generate_file_url($key){
1436 + public function generate_file_url($key){
1101 1437 $domain = $this->get_domain();
1102 1438
1103 1439 return apply_filters('wpmcs_generate_s3_file_url',
1104 1440 $domain . '/' . $key,
@@ -1103,8 +1439,17 @@
1103 1439 return apply_filters('wpmcs_generate_s3_file_url',
1104 1440 $domain . '/' . $key,
1105 1441 $domain, $key
1106 1442 );
1443 + }
1444 +
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);
1107 1452 }
1108 1453
1109 1454 /**
1110 1455 * Get domain URL