PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
← All changes | includes/base/services/cloudflare-r2.php +334 -147 1.3.10 → 1.4.2 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,16 +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 - Utils::set_status('cdnRead', $result);
420 423 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
421 424 }
422 425
423 426 try {
424 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
427 + $object_key = Utils::get_permission_check_object_key();
425 428
426 429 // Check if the object was created successfully
427 430 if (!$this->exists($object_key)) {
428 431 // Create a dummy object to check write permission
@@ -429,29 +432,37 @@
429 432 $this->cloudflareClient->putObject([
430 433 'Bucket' => $this->bucket_name,
431 434 'Key' => $object_key,
432 435 'Body' => 'This is a test object to check permission.',
436 + 'ContentType' => 'text/plain',
437 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
433 438 ]);
434 439 }
435 440
436 441 $url = $this->generate_file_url($object_key);
437 442 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
438 - $headers = @get_headers($cdn_url);
439 - 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) {
440 452 $result['status'] = true;
441 453 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
442 - } else if (strpos($headers[0], '403') !== false) {
454 + } else if ($status_code === 403) {
443 455 $result['status'] = false;
444 - if($this->cdnConfig['service'] == $this->service) {
456 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
445 457 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
446 458 } else {
447 459 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
448 460 }
449 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
450 - } else if (strpos($headers[0], '404') !== false) {
461 + } else if ($status_code === 404) {
451 462 $result['status'] = false;
452 463 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
453 - } else if (strpos($headers[0], '500') !== false) {
464 + } else if ($status_code === 500) {
454 465 $result['status'] = false;
455 466 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
456 467 } else {
457 468 $result['status'] = false;
@@ -456,9 +467,8 @@
456 467 } else {
457 468 $result['status'] = false;
458 469 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
459 470 }
460 - Utils::set_status('cdnRead', $result);
461 471
462 472 $this->deleteSingle($object_key);
463 473 return [
464 474 'message' => $result['message'],
@@ -467,17 +477,14 @@
467 477 'lastChecked' => $result['lastChecked'],
468 478 ];
469 479 } catch (AwsException $ex) {
470 480 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
471 - Utils::set_status('cdnRead', $result);
472 481 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false , 'lastChecked' => time()];
473 482 } catch (S3Exception $ex) {
474 483 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
475 - Utils::set_status('cdnRead', $result);
476 484 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false , 'lastChecked' => time()];
477 485 } catch (Exception $ex) {
478 486 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
479 - Utils::set_status('cdnRead', $result);
480 487 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false , 'lastChecked' => time()];
481 488 }
482 489 }
483 490
@@ -544,13 +551,66 @@
544 551 }
545 552 }
546 553
547 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 + /**
548 609 * Upload Single
549 610 * @since 1.0.0
550 611 */
551 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
552 - $result = array();
612 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
553 613 if (
554 614 isset($absolute_source_path) && !empty($absolute_source_path) &&
555 615 isset($relative_source_path) && !empty($relative_source_path)
556 616 ) {
@@ -555,98 +615,17 @@
555 615 isset($relative_source_path) && !empty($relative_source_path)
556 616 ) {
557 617 $file_name = wp_basename( $relative_source_path );
558 618 if ($file_name) {
559 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
560 -
561 - // Decide Multipart upload or normal put object
562 - if (filesize($absolute_source_path) <= Schema::getConstant('CLOUDFLARE_R2_MULTIPART_MIN_FILE_SIZE')) {
563 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
564 - try {
565 - $handle = fopen($absolute_source_path, 'rb');
566 -
567 - $this->cloudflareClient->putObject([
568 - 'Bucket' => $this->bucket_name,
569 - 'Key' => $upload_path,
570 - 'Body' => $handle,
571 - ]);
572 -
573 - if (is_resource($handle)) {
574 - fclose($handle);
575 - }
576 -
577 - return [
578 - 'success' => true,
579 - 'code' => 200,
580 - 'file_url' => $this->generate_file_url($upload_path),
581 - 'key' => $upload_path,
582 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
583 - ];
584 -
585 - } catch (AwsException $e) {
586 - return [
587 - 'success' => false,
588 - 'code' => 200,
589 - 'message' => $e->getMessage()
590 - ];
591 - }
592 - } else {
593 - $options = [
594 - 'bucket' => $this->bucket_name,
595 - 'key' => $upload_path,
596 - 'before_initiate' => function ($params) {
597 - return $this->strip_acl($params);
598 - },
599 - 'before_upload' => function ($params) {
600 - return $this->strip_acl($params);
601 - },
602 - 'before_complete' => function ($params) {
603 - return $this->strip_acl($params);
604 - },
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')
605 625 ];
606 -
607 - try {
608 - $multiUploader = new MultipartUploader(
609 - $this->cloudflareClient,
610 - $absolute_source_path,
611 - $options
612 - );
613 -
614 - do {
615 - try {
616 - $uploaded = $multiUploader->upload();
617 - } catch (MultipartUploadException $e) {
618 - $options['state'] = $e->getState();
619 - $multiUploader = new MultipartUploader(
620 - $this->cloudflareClient,
621 - $absolute_source_path,
622 - $options
623 - );
624 - }
625 - } while (!isset($uploaded));
626 -
627 - return [
628 - 'success' => true,
629 - 'code' => 200,
630 - 'file_url' => $this->generate_file_url($upload_path),
631 - 'key' => $upload_path,
632 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
633 - ];
634 -
635 - } catch (MultipartUploadException $e) {
636 - return [
637 - 'success' => false,
638 - 'code' => 200,
639 - 'message' => $e->getMessage()
640 - ];
641 - } catch (AwsException $e) {
642 - return [
643 - 'success' => false,
644 - 'code' => 200,
645 - 'message' => $e->getMessage()
646 - ];
647 - }
648 626 }
627 + return $this->execute_upload($absolute_source_path, $upload_path);
649 628 }
650 629 }
651 630
652 631 return [
@@ -655,10 +634,96 @@
655 634 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
656 635 ];
657 636 }
658 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 + }
659 645
660 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 + /**
661 726 * Exclude 'ACL' parameter from given params.
662 727 *
663 728 * @param Command|array $params Command or array of parameters.
664 729 *
@@ -665,11 +730,13 @@
665 730 * @return Command|array Modified params.
666 731 */
667 732 private function strip_acl($params) {
668 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.
669 737 if ($params->hasParam('ACL')) {
670 - $params = clone $params;
671 - $params->removeParam('ACL');
738 + unset($params['ACL']);
672 739 }
673 740 } else if (is_array($params)) {
674 741 if (isset($params['ACL'])) {
675 742 unset($params['ACL']);
@@ -682,8 +749,9 @@
682 749 * Save object to server
683 750 * @since 1.0.0
684 751 */
685 752 public function object_to_server($key, $save_path) {
753 + if(!$this->cloudflareClient) return false;
686 754 try {
687 755 $getObject = $this->cloudflareClient->getObject([
688 756 'Bucket' => $this->bucket_name,
689 757 'Key' => $key,
@@ -698,38 +766,112 @@
698 766 return false;
699 767 }
700 768
701 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 + /**
702 846 * Copy to new path
703 847 * @since 1.3.4
704 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.
705 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 + }
706 862 try {
707 - // Step 1: Verify object exists at old location
708 - if (!$this->exists($key)) {
709 - return [
710 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
711 - 'code' => 200,
712 - 'success' => false
713 - ];
714 - }
715 - // Step 2: Copy object
716 - if (!$this->exists($new_path)) {
717 - $this->cloudflareClient->copyObject([
718 - 'Bucket' => $this->bucket_name,
719 - 'CopySource' => "{$this->bucket_name}/{$key}",
720 - 'Key' => $new_path,
721 - 'MetadataDirective' => 'COPY',
722 - ]);
723 - }
724 - // Step 3: Verify object exists at new location
725 - if ($this->exists($new_path)) {
726 - return [
727 - 'success' => true,
728 - 'code' => 200,
729 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
730 - ];
731 - }
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 + ];
732 874 } catch (AwsException $e) {
733 875 return [
734 876 'success' => false,
735 877 'code' => 200,
@@ -735,15 +877,41 @@
735 877 'code' => 200,
736 878 'message' => $e->getMessage()
737 879 ];
738 880 }
739 - return [
740 - 'success' => false,
741 - 'code' => 200,
742 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
743 - ];
744 881 }
745 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 +
746 914 /**
747 915 * Delete Single
748 916 * @since 1.0.0
749 917 */
@@ -748,8 +916,15 @@
748 916 * @since 1.0.0
749 917 */
750 918 public function deleteSingle($key) {
751 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 + }
752 927 if (isset($key) && !empty($key)) {
753 928 try {
754 929 $this->cloudflareClient->deleteObject([
755 930 'Bucket' => $this->bucket_name,
@@ -791,8 +966,15 @@
791 966 * @since 1.0.0
792 967 */
793 968 public function get_private_url($key) {
794 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 + }
795 977 if (isset($key) && !empty($key)) {
796 978 try {
797 979 $cmd = $this->cloudflareClient->getCommand('GetObject', [
798 980 'Bucket' => $this->bucket_name,
@@ -886,6 +1068,11 @@
886 1068 return "https://{$account_id}.r2.cloudflarestorage.com";
887 1069 }
888 1070 // fallback
889 1071 return "https://r2.cloudflarestorage.com";
1072 + }
1073 +
1074 + /** Exposes the already-constructed SDK client for StreamWrapper's registration — avoids reconstructing one from credentials. */
1075 + public function get_client() {
1076 + return $this->cloudflareClient;
890 1077 }
891 1078 }