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/s3compatible.php +352 -139 1.3.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 S3Compatible {
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'])
@@ -90,9 +95,9 @@
90 95 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
91 96 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
92 97 $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
93 98 $region = isset($config['region']) ? $config['region'] : 'us-east-1';
94 - if (!empty($endpoint) && !empty($access_key) && !empty($secret_key)) {
99 + if (!Service::has_missing_fields([$endpoint, $access_key, $secret_key])) {
95 100 try {
96 101 $S3CompatibleClient = new S3Client([
97 102 'version' => '2006-03-01',
98 103 'region' => $region ? $region : 'us-east-1',
@@ -104,9 +109,9 @@
104 109 'key' => $access_key,
105 110 'secret' => $secret_key,
106 111 ],
107 112 ]);
108 -
113 +
109 114 $result = [
110 115 'success' => false,
111 116 'code' => 200,
112 117 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -172,9 +177,9 @@
172 177 $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
173 178 $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
174 179 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
175 180
176 - if ( !empty($endpoint) && !empty($access_key) && !empty($secret_key) && !empty($bucket_name) ) {
181 + if ( !Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name]) ) {
177 182 try {
178 183 $S3CompatibleClient = new S3Client([
179 184 'version' => '2006-03-01',
180 185 'region' => $region ? $region : 'us-east-1',
@@ -229,9 +234,9 @@
229 234 $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
230 235 $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
231 236 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
232 237
233 - if (empty($access_key) || empty($secret_key) || empty($bucket_name) || empty($endpoint)) {
238 + if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $endpoint])) {
234 239 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
235 240 }
236 241
237 242 try {
@@ -282,9 +287,9 @@
282 287 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
283 288 $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
284 289 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
285 290
286 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
291 + if (Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name])) {
287 292 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
288 293 }
289 294
290 295 try {
@@ -300,9 +305,9 @@
300 305 'secret' => $secret_key,
301 306 ],
302 307 ]);
303 308
304 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
309 + $object_key = Utils::get_permission_check_object_key();
305 310
306 311
307 312 // Create a dummy object to check write permission
308 313 $S3CompatibleClient->putObject([
@@ -337,10 +342,10 @@
337 342 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
338 343 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
339 344 $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
340 345 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
341 -
342 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
346 +
347 + if (Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name])) {
343 348 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
344 349 }
345 350
346 351 try {
@@ -356,9 +361,9 @@
356 361 'secret' => $secret_key,
357 362 ],
358 363 ]);
359 364
360 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
365 + $object_key = Utils::get_permission_check_object_key();
361 366
362 367 // Create a dummy object to check dlete permission
363 368 $S3CompatibleClient->deleteObject([
364 369 'Bucket' => $bucket_name,
@@ -392,15 +397,15 @@
392 397 'message' => '',
393 398 'lastChecked' => time(),
394 399 ];
395 400
396 - if (empty($this->S3CompatibleClient) || empty($this->bucket_name)) {
401 + if (Service::has_missing_fields([$this->S3CompatibleClient, $this->bucket_name])) {
397 402 $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
398 403 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
399 404 }
400 405
401 406 try {
402 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
407 + $object_key = Utils::get_permission_check_object_key();
403 408
404 409 // Check if the object was created successfully
405 410 if (!$this->exists($object_key)) {
406 411 // Create a dummy object to check write permission
@@ -407,31 +412,38 @@
407 412 $this->S3CompatibleClient->putObject([
408 413 'Bucket' => $this->bucket_name,
409 414 'Key' => $object_key,
410 415 'Body' => 'This is a test object to check permission.',
416 + 'ContentType' => 'text/plain',
417 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
411 418 ]);
412 - }
413 -
419 + }
414 420
421 +
415 422 $url = $this->generate_file_url($object_key);
416 423 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
417 - $headers = @get_headers($cdn_url);
424 + // Never trust a cached response for this fixed, predictable URL — a stale cached
425 + // error would otherwise keep failing the check long after real access is fine.
426 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
427 + $headers = @get_headers($cdn_url, false, $no_cache_context);
428 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
429 + ? (int) $matches[1]
430 + : 0;
418 431
419 - if (strpos($headers[0], '200') !== false) {
432 + if ($status_code === 200) {
420 433 $result['status'] = true;
421 434 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
422 - } else if (strpos($headers[0], '403') !== false) {
435 + } else if ($status_code === 403) {
423 436 $result['status'] = false;
424 - if($this->cdnConfig['service'] == $this->service) {
437 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
425 438 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
426 439 } else {
427 440 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
428 441 }
429 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
430 - } else if (strpos($headers[0], '404') !== false) {
442 + } else if ($status_code === 404) {
431 443 $result['status'] = false;
432 444 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
433 - } else if (strpos($headers[0], '500') !== false) {
445 + } else if ($status_code === 500) {
434 446 $result['status'] = false;
435 447 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
436 448 } else {
437 449 $result['status'] = false;
@@ -504,8 +516,9 @@
504 516 *
505 517 */
506 518 public function toPrivate($key) {
507 519 if(!$key) return false;
520 + if(!$this->S3CompatibleClient) return false;
508 521 try {
509 522 $this->S3CompatibleClient->putObjectAcl([
510 523 'Bucket' => $this->bucket_name,
511 524 'Key' => $key,
@@ -514,9 +527,8 @@
514 527 return true;
515 528 } catch (AwsException $ex) {
516 529 return false;
517 530 }
518 - return false;
519 531 }
520 532
521 533
522 534
@@ -522,23 +534,23 @@
522 534
523 535 /**
524 536 * Make Object Public
525 537 * @since 1.0.0
526 - *
538 + *
527 539 */
528 540 public function toPublic($key) {
529 541 if(!$key) return false;
542 + if(!$this->S3CompatibleClient) return false;
530 543 try {
531 544 $this->S3CompatibleClient->putObjectAcl([
532 545 'Bucket' => $this->bucket_name,
533 546 'Key' => $key,
534 547 'ACL' => 'public-read'
535 - ]);
548 + ]);
536 549 return true;
537 550 } catch (AwsException $ex) {
538 551 return false;
539 552 }
540 - return false;
541 553 }
542 554
543 555
544 556
@@ -554,8 +566,9 @@
554 566 $client = $client ?? $this->S3CompatibleClient;
555 567 if($client->doesObjectExistV2($bucket_name, $key)) {
556 568 return true;
557 569 }
570 + return false;
558 571 } catch (AwsException $ex) {
559 572 return false;
560 573 } catch (S3Exception $ex) {
561 574 return false;
@@ -564,14 +577,65 @@
564 577 }
565 578 }
566 579
567 580 /**
581 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
582 + * @since 1.3.13
583 + */
584 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
585 + if (!$this->S3CompatibleClient) {
586 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
587 + }
588 + try {
589 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
590 + if (!empty($delimiter)) {
591 + $params['Delimiter'] = $delimiter;
592 + }
593 + if (!empty($prefix)) {
594 + $params['Prefix'] = $prefix;
595 + }
596 + if (!empty($continuationToken)) {
597 + $params['ContinuationToken'] = $continuationToken;
598 + }
599 +
600 + $result = $this->S3CompatibleClient->listObjectsV2($params);
601 + $folders = [];
602 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
603 + $folders[] = $common['Prefix'];
604 + }
605 + $objects = [];
606 + foreach (($result['Contents'] ?? []) as $object) {
607 + if ($object['Key'] === $prefix) {
608 + continue; // the folder placeholder object itself, not a file
609 + }
610 + $objects[] = [
611 + 'key' => $object['Key'],
612 + 'size' => (int) $object['Size'],
613 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
614 + ];
615 + }
616 +
617 + return [
618 + 'success' => true,
619 + 'code' => 200,
620 + 'message' => '',
621 + 'folders' => $folders,
622 + 'objects' => $objects,
623 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
624 + ];
625 + } catch (AwsException $e) {
626 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
627 + } catch (Exception $e) {
628 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
629 + }
630 + }
631 +
632 + /**
568 633 * Upload Single
569 634 * @since 1.0.0
570 635 * @return boolean
571 636 */
572 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
573 - $result = array();
637 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
574 638 if (
575 639 isset($absolute_source_path) && !empty($absolute_source_path) &&
576 640 isset($relative_source_path) && !empty($relative_source_path)
577 641 ) {
@@ -576,93 +640,122 @@
576 640 isset($relative_source_path) && !empty($relative_source_path)
577 641 ) {
578 642 $file_name = wp_basename( $relative_source_path );
579 643 if ($file_name) {
580 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
581 -
582 - // Decide Multipart upload or normal put object
583 - if (filesize($absolute_source_path) <= Schema::getConstant('S3COMPATIBLE_MULTIPART_MIN_FILE_SIZE')) {
584 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
585 - try {
586 - $handle = fopen($absolute_source_path, 'rb');
587 - $upload = $this->S3CompatibleClient->putObject([
588 - 'Bucket' => $this->bucket_name,
589 - 'Key' => $upload_path,
590 - 'Body' => $handle
591 - ]);
592 - if (is_resource($handle)) {
593 - fclose($handle);
594 - }
644 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
645 + if ($upload_path === false) {
646 + return [
647 + 'success' => false,
648 + 'code' => 200,
649 + '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')
650 + ];
651 + }
652 + return $this->execute_upload($absolute_source_path, $upload_path);
653 + }
654 + return [
655 + 'success' => false,
656 + 'code' => 200,
657 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
658 + ];
659 + }
660 + return [
661 + 'success' => false,
662 + 'code' => 200,
663 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
664 + ];
665 + }
595 666
596 - $result = array(
597 - 'success' => true,
598 - 'code' => 200,
599 - 'file_url' => $this->generate_file_url($upload_path),
600 - 'key' => $upload_path,
601 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
602 - );
603 - } catch (AwsException $e) {
604 - $result = array(
605 - 'success' => false,
606 - 'code' => 200,
607 - 'message' => $e->getMessage()
608 - );
609 - }
610 - } else {
611 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $absolute_source_path, [
612 - 'bucket' => $this->bucket_name,
613 - 'key' => $upload_path
614 - ]);
615 -
616 - try {
617 - do {
618 - try {
619 - $uploaded = $multiUploader->upload();
620 - } catch (MultipartUploadException $e) {
621 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $absolute_source_path, [
622 - 'state' => $e->getState(),
623 - ]);
624 - }
625 - } while (!isset($uploaded));
667 + /**
668 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
669 + * @since 1.4.0
670 + */
671 + public function uploadObjectAtKey($absolute_source_path, $key) {
672 + return $this->execute_upload($absolute_source_path, $key);
673 + }
626 674
627 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
628 - $result = array(
629 - 'success' => true,
630 - 'code' => 200,
631 - 'file_url' => $this->generate_file_url($upload_path),
632 - 'key' => $upload_path,
633 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
634 - );
635 - } else {
636 - $result = array(
637 - 'success' => false,
638 - 'code' => 200,
639 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
640 - );
641 - }
642 - } catch (MultipartUploadException $e) {
643 - $result = array(
644 - 'success' => false,
645 - 'code' => 200,
646 - 'message' => $e->getMessage()
647 - );
648 - }
675 + /**
676 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
677 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
678 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
679 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
680 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
681 + * previously-failed multipart attempt).
682 + * @since 1.4.0
683 + */
684 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
685 + $handle = fopen($absolute_source_path, 'rb');
686 + $params = [];
687 + $cache_control = Utils::get_cache_control_header();
688 + if ($cache_control) {
689 + $params['CacheControl'] = $cache_control;
690 + }
691 + $options += [
692 + 'mup_threshold' => Schema::getConstant('S3COMPATIBLE_MULTIPART_MIN_FILE_SIZE'),
693 + 'params' => $params,
694 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
695 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
696 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
697 + ];
698 + return new ObjectUploader($this->S3CompatibleClient, $this->bucket_name, $key, $handle, null, $options);
699 + }
700 +
701 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
702 + // return value, relying on the same Command object being modified.
703 + private function strip_acl($params) {
704 + if ($params instanceof Command && $params->hasParam('ACL')) {
705 + unset($params['ACL']);
706 + } elseif (is_array($params) && isset($params['ACL'])) {
707 + unset($params['ACL']);
708 + }
709 + return $params;
710 + }
711 +
712 + /**
713 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
714 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
715 + * state rather than restarting the whole upload — same retry contract uploadSingle()
716 + * had before the ObjectUploader swap.
717 + * @since 1.4.0
718 + */
719 + private function execute_upload($absolute_source_path, $key) {
720 + $max_attempts = 3;
721 + $attempt = 0;
722 + $options = [];
723 +
724 + while (true) {
725 + $attempt++;
726 + try {
727 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
728 + return [
729 + 'success' => true,
730 + 'code' => 200,
731 + 'file_url' => $this->generate_file_url($key),
732 + 'key' => $key,
733 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
734 + ];
735 + } catch (MultipartUploadException $e) {
736 + if ($attempt >= $max_attempts) {
737 + return [
738 + 'success' => false,
739 + 'code' => 200,
740 + 'message' => $e->getMessage()
741 + ];
649 742 }
650 - } else {
651 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
652 746 'success' => false,
653 747 'code' => 200,
654 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
655 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
656 756 }
657 - } else {
658 - $result = array(
659 - 'success' => false,
660 - 'code' => 200,
661 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
662 - );
663 757 }
664 - return $result;
665 758 }
666 759
667 760 /**
668 761 * Save object to server
@@ -668,8 +761,9 @@
668 761 * Save object to server
669 762 * @since 1.0.0
670 763 */
671 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
672 766 try {
673 767 $getObject = $this->S3CompatibleClient->GetObject([
674 768 'Bucket' => $this->bucket_name,
675 769 'Key' => $key,
@@ -683,51 +777,156 @@
683 777 }
684 778 return false;
685 779 }
686 780
781 + /**
782 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
783 + * the content itself rather than a copy on the server's filesystem.
784 + * @since 1.3.13
785 + */
786 + public function get_object_content($key) {
787 + if(!$this->S3CompatibleClient) return false;
788 + try {
789 + $result = $this->S3CompatibleClient->GetObject([
790 + 'Bucket' => $this->bucket_name,
791 + 'Key' => $key,
792 + ]);
793 + return (string) $result['Body'];
794 + } catch (AwsException $e) {
795 + return false;
796 + }
797 + }
687 798
688 799 /**
800 + * Deletes the live object, then best-effort purges every historical version too — a
801 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
802 + * versions (and the storage they use) behind at the old key. The live delete happens
803 + * unconditionally first: not every S3-compatible endpoint supports ListObjectVersions
804 + * (confirmed missing on Cloudflare R2, a live 501 "NotImplemented"), and the object must
805 + * still end up gone either way.
806 + * @since 1.3.14
807 + */
808 + public function purge_all_versions($key) {
809 + if (!$this->S3CompatibleClient) {
810 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
811 + }
812 +
813 + try {
814 + $this->S3CompatibleClient->deleteObject([
815 + 'Bucket' => $this->bucket_name,
816 + 'Key' => $key,
817 + ]);
818 + } catch (AwsException $e) {
819 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
820 + }
821 +
822 + // Best-effort only from here — providers that don't support version listing simply
823 + // skip this part; the live object above is already gone regardless.
824 + try {
825 + $objects = [];
826 + $marker = null;
827 + do {
828 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
829 + if ($marker) {
830 + $args['KeyMarker'] = $marker['key'];
831 + $args['VersionIdMarker'] = $marker['version'];
832 + }
833 + $result = $this->S3CompatibleClient->listObjectVersions($args);
834 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
835 + if (($version['Key'] ?? null) === $key) {
836 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
837 + }
838 + }
839 + $marker = !empty($result['IsTruncated'])
840 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
841 + : null;
842 + } while ($marker);
843 +
844 + foreach (array_chunk($objects, 1000) as $chunk) {
845 + $this->S3CompatibleClient->deleteObjects([
846 + 'Bucket' => $this->bucket_name,
847 + 'Delete' => ['Objects' => $chunk],
848 + ]);
849 + }
850 + } catch (AwsException $e) {
851 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
852 + }
853 +
854 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
855 + }
856 +
857 +
858 + /**
689 859 * Copy to new path
690 860 * @since 1.3.4
691 861 */
862 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
863 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
864 + // processing keys sequentially, three extra round-trips per file adds up fast on a
865 + // folder with many files. copyObject() itself throws (caught below) if the source is
866 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
692 867 public function copy_to_new_path($key, $new_path) {
868 + if (!$this->S3CompatibleClient) {
869 + return [
870 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
871 + 'code' => 200,
872 + 'success' => false
873 + ];
874 + }
693 875 try {
694 - // Step 1: Verify object exists at old location
695 - if (!$this->exists($key)) {
696 - return [
697 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
698 - 'code' => 200,
699 - 'success' => false
700 - ];
701 - }
702 - // Step 2: Copy object
703 - if (!$this->exists($new_path)) {
704 - $this->S3CompatibleClient->copyObject([
705 - 'Bucket' => $this->bucket_name,
706 - 'CopySource' => "{$this->bucket_name}/{$key}",
707 - 'Key' => $new_path,
708 - 'MetadataDirective' => 'COPY',
709 - ]);
710 - }
711 - // Step 3: Verify object exists at new location
712 - if ($this->exists($new_path)) {
713 - return [
714 - 'success' => true,
715 - 'code' => 200,
716 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
717 - ];
718 - }
876 + $this->S3CompatibleClient->copyObject([
877 + 'Bucket' => $this->bucket_name,
878 + 'CopySource' => "{$this->bucket_name}/{$key}",
879 + 'Key' => $new_path,
880 + 'MetadataDirective' => 'COPY',
881 + ]);
882 + return [
883 + 'success' => true,
884 + 'code' => 200,
885 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
886 + ];
719 887 } catch (AwsException $e) {
720 - return false;
888 + return [
889 + 'success' => false,
890 + 'code' => 200,
891 + 'message' => $e->getMessage()
892 + ];
721 893 }
722 - return [
723 - 'success' => false,
724 - 'code' => 200,
725 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
726 - ];
727 894 }
728 895
896 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
897 + // access there too (and the same endpoint), so callers should fall back to download+upload
898 + // on failure.
899 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
900 + if (!$this->S3CompatibleClient) {
901 + return [
902 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
903 + 'code' => 200,
904 + 'success' => false
905 + ];
906 + }
907 + try {
908 + $this->S3CompatibleClient->copyObject([
909 + 'Bucket' => $dest_bucket,
910 + 'CopySource' => "{$this->bucket_name}/{$key}",
911 + 'Key' => $new_key,
912 + 'MetadataDirective' => 'COPY',
913 + ]);
914 + return [
915 + 'success' => true,
916 + 'code' => 200,
917 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
918 + ];
919 + } catch (AwsException $e) {
920 + return [
921 + 'success' => false,
922 + 'code' => 200,
923 + 'message' => $e->getMessage()
924 + ];
925 + }
926 + }
729 927
928 +
730 929 /**
731 930 * Delete Single
732 931 * @since 1.0.0
733 932 * @return boolean
@@ -733,8 +932,15 @@
733 932 * @return boolean
734 933 */
735 934 public function deleteSingle($key) {
736 935 $result = array();
936 + if (!$this->S3CompatibleClient) {
937 + return array(
938 + 'success' => false,
939 + 'code' => 200,
940 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
941 + );
942 + }
737 943 if (isset($key) && !empty($key)) {
738 944 try {
739 945 $this->S3CompatibleClient->deleteObject([
740 946 'Bucket' => $this->bucket_name,
@@ -777,8 +983,15 @@
777 983 * @return boolean
778 984 */
779 985 public function get_private_url($key) {
780 986 $result = array();
987 + if (!$this->S3CompatibleClient) {
988 + return array(
989 + 'success' => false,
990 + 'code' => 200,
991 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
992 + );
993 + }
781 994 if (isset($key) && !empty($key)) {
782 995 try {
783 996 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
784 997 'Bucket' => $this->bucket_name,