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/cloudflare-r2.php +329 -142 1.3.111.4.1 View file →
@@ -9,8 +9,9 @@
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 12 use Dudlewebs\WPMCS\s3\Aws\Command;
13 +use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 14 use Exception;
14 15
15 16 class CloudflareR2 {
16 17 private $assets_url;
@@ -30,23 +31,26 @@
30 31 /**
31 32 * Admin constructor.
32 33 * @since 1.0.0
33 34 */
34 - public function __construct() {
35 + public function __construct($credentials = null) {
35 36 $this->assets_url = WPMCS_ASSETS_URL;
36 37 $this->version = WPMCS_VERSION;
37 38 $this->token = WPMCS_TOKEN;
38 39
39 40 // Initialize setup
40 - $this->init();
41 + $this->init($credentials);
41 42 }
42 43
43 44 /**
44 45 * Initialise Client
46 + *
47 + * @param array|null $credentials Optional explicit credentials; falls back to
48 + * Utils::get_credentials() when omitted.
45 49 */
46 - public function init() {
50 + public function init($credentials = null) {
47 51 $this->settings = Utils::get_settings();
48 - $this->credentials = Utils::get_credentials();
52 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
49 53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
50 54 ? $this->credentials['config']
51 55 : [];
52 56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -93,9 +97,9 @@
93 97 $access_key = isset($config['access_key']) ? $config['access_key'] : false;
94 98 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : false;
95 99 $region = isset($config['region']) ? $config['region'] : '';
96 100
97 - if (!empty($account_id) && !empty($access_key) && !empty($secret_key)) {
101 + if (!Service::has_missing_fields([$account_id, $access_key, $secret_key])) {
98 102 try {
99 103 $cloudflareClient = new S3Client([
100 104 'version' => '2006-03-01',
101 105 'region' => 'auto',
@@ -196,9 +200,9 @@
196 200 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : false;
197 201 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : false;
198 202 $region = isset($config['region']) ? $config['region'] : '';
199 203
200 - if ( empty($account_id) || empty($access_key) || empty($secret_key) || empty($bucket_name) ) {
204 + if ( Service::has_missing_fields([$account_id, $access_key, $secret_key, $bucket_name]) ) {
201 205 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
202 206 }
203 207
204 208 try {
@@ -253,9 +257,9 @@
253 257 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
254 258 $region = isset($config['region']) ? $config['region'] : '';
255 259 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
256 260
257 - if ( empty($account_id) || empty($access_key) || empty($secret_key) || empty($bucket_name) ) {
261 + if ( Service::has_missing_fields([$account_id, $access_key, $secret_key, $bucket_name]) ) {
258 262 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
259 263 }
260 264
261 265 try {
@@ -304,9 +308,9 @@
304 308 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
305 309 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
306 310 $region = isset($config['region']) ? $config['region'] : '';
307 311
308 - if (empty($access_key) || empty($secret_key) || empty($bucket_name ) || empty($account_id)) {
312 + if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $account_id])) {
309 313 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
310 314 }
311 315
312 316 try {
@@ -323,9 +327,9 @@
323 327 'secret' => $secret_key,
324 328 ],
325 329 ]);
326 330
327 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
331 + $object_key = Utils::get_permission_check_object_key();
328 332
329 333 // create an empty object to test write permission
330 334 $cloudflareClient->putObject([
331 335 'Bucket' => $bucket_name,
@@ -359,9 +363,9 @@
359 363 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
360 364 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
361 365 $region = isset($config['region']) ? $config['region'] : '';
362 366
363 - if (empty($access_key) || empty($secret_key) || empty($bucket_name) || empty($account_id)) {
367 + if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $account_id])) {
364 368 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
365 369 }
366 370
367 371 try {
@@ -378,9 +382,9 @@
378 382 'secret' => $secret_key,
379 383 ],
380 384 ]);
381 385
382 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
386 + $object_key = Utils::get_permission_check_object_key();
383 387
384 388 // Create a dummy object to check delete permission
385 389 $cloudflareClient->deleteObject([
386 390 'Bucket' => $bucket_name,
@@ -413,15 +417,15 @@
413 417 'message' => '',
414 418 'lastChecked' => time(),
415 419 ];
416 420
417 - if (empty($this->cloudflareClient) || empty($this->bucket_name)) {
421 + if (Service::has_missing_fields([$this->cloudflareClient, $this->bucket_name])) {
418 422 $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
419 423 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
420 424 }
421 425
422 426 try {
423 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
427 + $object_key = Utils::get_permission_check_object_key();
424 428
425 429 // Check if the object was created successfully
426 430 if (!$this->exists($object_key)) {
427 431 // Create a dummy object to check write permission
@@ -428,29 +432,37 @@
428 432 $this->cloudflareClient->putObject([
429 433 'Bucket' => $this->bucket_name,
430 434 'Key' => $object_key,
431 435 'Body' => 'This is a test object to check permission.',
436 + 'ContentType' => 'text/plain',
437 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
432 438 ]);
433 439 }
434 440
435 441 $url = $this->generate_file_url($object_key);
436 442 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
437 - $headers = @get_headers($cdn_url);
438 - if (strpos($headers[0], '200') !== false) {
443 + // Never trust a cached response for this fixed, predictable URL — a stale cached
444 + // error would otherwise keep failing the check long after real access is fine.
445 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
446 + $headers = @get_headers($cdn_url, false, $no_cache_context);
447 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
448 + ? (int) $matches[1]
449 + : 0;
450 +
451 + if ($status_code === 200) {
439 452 $result['status'] = true;
440 453 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
441 - } else if (strpos($headers[0], '403') !== false) {
454 + } else if ($status_code === 403) {
442 455 $result['status'] = false;
443 - if($this->cdnConfig['service'] == $this->service) {
456 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
444 457 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
445 458 } else {
446 459 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
447 460 }
448 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
449 - } else if (strpos($headers[0], '404') !== false) {
461 + } else if ($status_code === 404) {
450 462 $result['status'] = false;
451 463 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
452 - } else if (strpos($headers[0], '500') !== false) {
464 + } else if ($status_code === 500) {
453 465 $result['status'] = false;
454 466 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
455 467 } else {
456 468 $result['status'] = false;
@@ -539,13 +551,66 @@
539 551 }
540 552 }
541 553
542 554 /**
555 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
556 + * @since 1.3.13
557 + */
558 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
559 + if (!$this->cloudflareClient) {
560 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
561 + }
562 + try {
563 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
564 + if (!empty($delimiter)) {
565 + $params['Delimiter'] = $delimiter;
566 + }
567 + if (!empty($prefix)) {
568 + $params['Prefix'] = $prefix;
569 + }
570 + if (!empty($continuationToken)) {
571 + $params['ContinuationToken'] = $continuationToken;
572 + }
573 +
574 + $result = $this->cloudflareClient->listObjectsV2($params);
575 + $folders = [];
576 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
577 + $folders[] = $common['Prefix'];
578 + }
579 + $objects = [];
580 + foreach (($result['Contents'] ?? []) as $object) {
581 + if ($object['Key'] === $prefix) {
582 + continue; // the folder placeholder object itself, not a file
583 + }
584 + $objects[] = [
585 + 'key' => $object['Key'],
586 + 'size' => (int) $object['Size'],
587 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
588 + ];
589 + }
590 +
591 + return [
592 + 'success' => true,
593 + 'code' => 200,
594 + 'message' => '',
595 + 'folders' => $folders,
596 + 'objects' => $objects,
597 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
598 + ];
599 + } catch (AwsException $e) {
600 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
601 + } catch (S3Exception $e) {
602 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
603 + } catch (Exception $e) {
604 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
605 + }
606 + }
607 +
608 + /**
543 609 * Upload Single
544 610 * @since 1.0.0
545 611 */
546 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
547 - $result = array();
612 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
548 613 if (
549 614 isset($absolute_source_path) && !empty($absolute_source_path) &&
550 615 isset($relative_source_path) && !empty($relative_source_path)
551 616 ) {
@@ -550,98 +615,17 @@
550 615 isset($relative_source_path) && !empty($relative_source_path)
551 616 ) {
552 617 $file_name = wp_basename( $relative_source_path );
553 618 if ($file_name) {
554 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
555 -
556 - // Decide Multipart upload or normal put object
557 - if (filesize($absolute_source_path) <= Schema::getConstant('CLOUDFLARE_R2_MULTIPART_MIN_FILE_SIZE')) {
558 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
559 - try {
560 - $handle = fopen($absolute_source_path, 'rb');
561 -
562 - $this->cloudflareClient->putObject([
563 - 'Bucket' => $this->bucket_name,
564 - 'Key' => $upload_path,
565 - 'Body' => $handle,
566 - ]);
567 -
568 - if (is_resource($handle)) {
569 - fclose($handle);
570 - }
571 -
572 - return [
573 - 'success' => true,
574 - 'code' => 200,
575 - 'file_url' => $this->generate_file_url($upload_path),
576 - 'key' => $upload_path,
577 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
578 - ];
579 -
580 - } catch (AwsException $e) {
581 - return [
582 - 'success' => false,
583 - 'code' => 200,
584 - 'message' => $e->getMessage()
585 - ];
586 - }
587 - } else {
588 - $options = [
589 - 'bucket' => $this->bucket_name,
590 - 'key' => $upload_path,
591 - 'before_initiate' => function ($params) {
592 - return $this->strip_acl($params);
593 - },
594 - 'before_upload' => function ($params) {
595 - return $this->strip_acl($params);
596 - },
597 - 'before_complete' => function ($params) {
598 - return $this->strip_acl($params);
599 - },
619 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
620 + if ($upload_path === false) {
621 + return [
622 + 'success' => false,
623 + 'code' => 200,
624 + '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')
600 625 ];
601 -
602 - try {
603 - $multiUploader = new MultipartUploader(
604 - $this->cloudflareClient,
605 - $absolute_source_path,
606 - $options
607 - );
608 -
609 - do {
610 - try {
611 - $uploaded = $multiUploader->upload();
612 - } catch (MultipartUploadException $e) {
613 - $options['state'] = $e->getState();
614 - $multiUploader = new MultipartUploader(
615 - $this->cloudflareClient,
616 - $absolute_source_path,
617 - $options
618 - );
619 - }
620 - } while (!isset($uploaded));
621 -
622 - return [
623 - 'success' => true,
624 - 'code' => 200,
625 - 'file_url' => $this->generate_file_url($upload_path),
626 - 'key' => $upload_path,
627 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
628 - ];
629 -
630 - } catch (MultipartUploadException $e) {
631 - return [
632 - 'success' => false,
633 - 'code' => 200,
634 - 'message' => $e->getMessage()
635 - ];
636 - } catch (AwsException $e) {
637 - return [
638 - 'success' => false,
639 - 'code' => 200,
640 - 'message' => $e->getMessage()
641 - ];
642 - }
643 626 }
627 + return $this->execute_upload($absolute_source_path, $upload_path);
644 628 }
645 629 }
646 630
647 631 return [
@@ -650,10 +634,96 @@
650 634 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
651 635 ];
652 636 }
653 637
638 + /**
639 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
640 + * @since 1.4.0
641 + */
642 + public function uploadObjectAtKey($absolute_source_path, $key) {
643 + return $this->execute_upload($absolute_source_path, $key);
644 + }
654 645
655 646 /**
647 + * Build an unexecuted ObjectUploader for the given local file and destination key, using
648 + * this plugin's own multipart threshold rather than the SDK's 16MB default. $acl is
649 + * explicitly null — R2 doesn't support S3-style ACLs — and the before_initiate/
650 + * before_upload/before_complete hooks strip it via strip_acl(), since ObjectUploader's
651 + * multipart delegation still threads $options through to MultipartUploader.
652 + * @since 1.4.0
653 + */
654 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
655 + $handle = fopen($absolute_source_path, 'rb');
656 + $params = [];
657 + $cache_control = Utils::get_cache_control_header();
658 + if ($cache_control) {
659 + $params['CacheControl'] = $cache_control;
660 + }
661 + $options += [
662 + 'mup_threshold' => Schema::getConstant('CLOUDFLARE_R2_MULTIPART_MIN_FILE_SIZE'),
663 + 'params' => $params,
664 + 'before_initiate' => function ($params) {
665 + return $this->strip_acl($params);
666 + },
667 + 'before_upload' => function ($params) {
668 + return $this->strip_acl($params);
669 + },
670 + 'before_complete' => function ($params) {
671 + return $this->strip_acl($params);
672 + },
673 + ];
674 + return new ObjectUploader($this->cloudflareClient, $this->bucket_name, $key, $handle, null, $options);
675 + }
676 +
677 + /**
678 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
679 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
680 + * state rather than restarting the whole upload — same retry contract uploadSingle()
681 + * had before the ObjectUploader swap.
682 + * @since 1.4.0
683 + */
684 + private function execute_upload($absolute_source_path, $key) {
685 + $max_attempts = 3;
686 + $attempt = 0;
687 + $options = [];
688 +
689 + while (true) {
690 + $attempt++;
691 + try {
692 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
693 + return [
694 + 'success' => true,
695 + 'code' => 200,
696 + 'file_url' => $this->generate_file_url($key),
697 + 'key' => $key,
698 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
699 + ];
700 + } catch (MultipartUploadException $e) {
701 + if ($attempt >= $max_attempts) {
702 + return [
703 + 'success' => false,
704 + 'code' => 200,
705 + 'message' => $e->getMessage()
706 + ];
707 + }
708 + $options = ['state' => $e->getState()];
709 + } catch (AwsException $e) {
710 + return [
711 + 'success' => false,
712 + 'code' => 200,
713 + 'message' => $e->getMessage()
714 + ];
715 + } catch (Exception $e) {
716 + return [
717 + 'success' => false,
718 + 'code' => 200,
719 + 'message' => $e->getMessage()
720 + ];
721 + }
722 + }
723 + }
724 +
725 + /**
656 726 * Exclude 'ACL' parameter from given params.
657 727 *
658 728 * @param Command|array $params Command or array of parameters.
659 729 *
@@ -660,11 +730,13 @@
660 730 * @return Command|array Modified params.
661 731 */
662 732 private function strip_acl($params) {
663 733 if ($params instanceof Command) {
734 + // Mutate in place, not a clone — the SDK's before_* hooks (both here and in
735 + // MultipartUploader) call this and discard the return value, relying on the
736 + // same Command object being modified, not a modified copy being returned.
664 737 if ($params->hasParam('ACL')) {
665 - $params = clone $params;
666 - $params->removeParam('ACL');
738 + unset($params['ACL']);
667 739 }
668 740 } else if (is_array($params)) {
669 741 if (isset($params['ACL'])) {
670 742 unset($params['ACL']);
@@ -677,8 +749,9 @@
677 749 * Save object to server
678 750 * @since 1.0.0
679 751 */
680 752 public function object_to_server($key, $save_path) {
753 + if(!$this->cloudflareClient) return false;
681 754 try {
682 755 $getObject = $this->cloudflareClient->getObject([
683 756 'Bucket' => $this->bucket_name,
684 757 'Key' => $key,
@@ -693,38 +766,112 @@
693 766 return false;
694 767 }
695 768
696 769 /**
770 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
771 + * the content itself rather than a copy on the server's filesystem.
772 + * @since 1.3.13
773 + */
774 + public function get_object_content($key) {
775 + if(!$this->cloudflareClient) return false;
776 + try {
777 + $result = $this->cloudflareClient->getObject([
778 + 'Bucket' => $this->bucket_name,
779 + 'Key' => $key,
780 + ]);
781 + return (string) $result['Body'];
782 + } catch (AwsException $e) {
783 + return false;
784 + }
785 + }
786 +
787 + /**
788 + * Deletes the live object, then best-effort purges every historical version too — a
789 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
790 + * versions (and the storage they use) behind at the old key. The live delete happens
791 + * unconditionally first: some providers (Cloudflare R2 itself doesn't support
792 + * ListObjectVersions at all — confirmed via a live 501 "NotImplemented" response) can't
793 + * do the version-history part, and the object must still end up gone either way.
794 + * @since 1.3.14
795 + */
796 + public function purge_all_versions($key) {
797 + if (!$this->cloudflareClient) {
798 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
799 + }
800 +
801 + try {
802 + $this->cloudflareClient->deleteObject([
803 + 'Bucket' => $this->bucket_name,
804 + 'Key' => $key,
805 + ]);
806 + } catch (AwsException $e) {
807 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
808 + }
809 +
810 + // Best-effort only from here — providers that don't support version listing (R2)
811 + // simply skip this part; the live object above is already gone regardless.
812 + try {
813 + $objects = [];
814 + $marker = null;
815 + do {
816 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
817 + if ($marker) {
818 + $args['KeyMarker'] = $marker['key'];
819 + $args['VersionIdMarker'] = $marker['version'];
820 + }
821 + $result = $this->cloudflareClient->listObjectVersions($args);
822 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
823 + if (($version['Key'] ?? null) === $key) {
824 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
825 + }
826 + }
827 + $marker = !empty($result['IsTruncated'])
828 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
829 + : null;
830 + } while ($marker);
831 +
832 + foreach (array_chunk($objects, 1000) as $chunk) {
833 + $this->cloudflareClient->deleteObjects([
834 + 'Bucket' => $this->bucket_name,
835 + 'Delete' => ['Objects' => $chunk],
836 + ]);
837 + }
838 + } catch (AwsException $e) {
839 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
840 + }
841 +
842 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
843 + }
844 +
845 + /**
697 846 * Copy to new path
698 847 * @since 1.3.4
699 848 */
849 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
850 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
851 + // processing keys sequentially, three extra round-trips per file adds up fast on a
852 + // folder with many files. copyObject() itself throws (caught below) if the source is
853 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
700 854 public function copy_to_new_path($key, $new_path) {
855 + if (!$this->cloudflareClient) {
856 + return [
857 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
858 + 'code' => 200,
859 + 'success' => false
860 + ];
861 + }
701 862 try {
702 - // Step 1: Verify object exists at old location
703 - if (!$this->exists($key)) {
704 - return [
705 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
706 - 'code' => 200,
707 - 'success' => false
708 - ];
709 - }
710 - // Step 2: Copy object
711 - if (!$this->exists($new_path)) {
712 - $this->cloudflareClient->copyObject([
713 - 'Bucket' => $this->bucket_name,
714 - 'CopySource' => "{$this->bucket_name}/{$key}",
715 - 'Key' => $new_path,
716 - 'MetadataDirective' => 'COPY',
717 - ]);
718 - }
719 - // Step 3: Verify object exists at new location
720 - if ($this->exists($new_path)) {
721 - return [
722 - 'success' => true,
723 - 'code' => 200,
724 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
725 - ];
726 - }
863 + $this->cloudflareClient->copyObject([
864 + 'Bucket' => $this->bucket_name,
865 + 'CopySource' => "{$this->bucket_name}/{$key}",
866 + 'Key' => $new_path,
867 + 'MetadataDirective' => 'COPY',
868 + ]);
869 + return [
870 + 'success' => true,
871 + 'code' => 200,
872 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
873 + ];
727 874 } catch (AwsException $e) {
728 875 return [
729 876 'success' => false,
730 877 'code' => 200,
@@ -730,15 +877,41 @@
730 877 'code' => 200,
731 878 'message' => $e->getMessage()
732 879 ];
733 880 }
734 - return [
735 - 'success' => false,
736 - 'code' => 200,
737 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
738 - ];
739 881 }
740 882
883 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
884 + // access there too, so callers should fall back to download+upload on failure.
885 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
886 + if (!$this->cloudflareClient) {
887 + return [
888 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
889 + 'code' => 200,
890 + 'success' => false
891 + ];
892 + }
893 + try {
894 + $this->cloudflareClient->copyObject([
895 + 'Bucket' => $dest_bucket,
896 + 'CopySource' => "{$this->bucket_name}/{$key}",
897 + 'Key' => $new_key,
898 + 'MetadataDirective' => 'COPY',
899 + ]);
900 + return [
901 + 'success' => true,
902 + 'code' => 200,
903 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
904 + ];
905 + } catch (AwsException $e) {
906 + return [
907 + 'success' => false,
908 + 'code' => 200,
909 + 'message' => $e->getMessage()
910 + ];
911 + }
912 + }
913 +
741 914 /**
742 915 * Delete Single
743 916 * @since 1.0.0
744 917 */
@@ -743,8 +916,15 @@
743 916 * @since 1.0.0
744 917 */
745 918 public function deleteSingle($key) {
746 919 $result = array();
920 + if (!$this->cloudflareClient) {
921 + return array(
922 + 'success' => false,
923 + 'code' => 200,
924 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
925 + );
926 + }
747 927 if (isset($key) && !empty($key)) {
748 928 try {
749 929 $this->cloudflareClient->deleteObject([
750 930 'Bucket' => $this->bucket_name,
@@ -786,8 +966,15 @@
786 966 * @since 1.0.0
787 967 */
788 968 public function get_private_url($key) {
789 969 $result = array();
970 + if (!$this->cloudflareClient) {
971 + return array(
972 + 'success' => false,
973 + 'code' => 200,
974 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
975 + );
976 + }
790 977 if (isset($key) && !empty($key)) {
791 978 try {
792 979 $cmd = $this->cloudflareClient->getCommand('GetObject', [
793 980 'Bucket' => $this->bucket_name,