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 -144 1.3.101.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,16 +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 - Utils::set_status('cdnRead', $result);
399 403 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
400 404 }
401 405
402 406 try {
403 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
407 + $object_key = Utils::get_permission_check_object_key();
404 408
405 409 // Check if the object was created successfully
406 410 if (!$this->exists($object_key)) {
407 411 // Create a dummy object to check write permission
@@ -408,31 +412,38 @@
408 412 $this->S3CompatibleClient->putObject([
409 413 'Bucket' => $this->bucket_name,
410 414 'Key' => $object_key,
411 415 'Body' => 'This is a test object to check permission.',
416 + 'ContentType' => 'text/plain',
417 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
412 418 ]);
413 - }
414 -
419 + }
415 420
421 +
416 422 $url = $this->generate_file_url($object_key);
417 423 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
418 - $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;
419 431
420 - if (strpos($headers[0], '200') !== false) {
432 + if ($status_code === 200) {
421 433 $result['status'] = true;
422 434 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
423 - } else if (strpos($headers[0], '403') !== false) {
435 + } else if ($status_code === 403) {
424 436 $result['status'] = false;
425 - if($this->cdnConfig['service'] == $this->service) {
437 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
426 438 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
427 439 } else {
428 440 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
429 441 }
430 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
431 - } else if (strpos($headers[0], '404') !== false) {
442 + } else if ($status_code === 404) {
432 443 $result['status'] = false;
433 444 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
434 - } else if (strpos($headers[0], '500') !== false) {
445 + } else if ($status_code === 500) {
435 446 $result['status'] = false;
436 447 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
437 448 } else {
438 449 $result['status'] = false;
@@ -437,9 +448,8 @@
437 448 } else {
438 449 $result['status'] = false;
439 450 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
440 451 }
441 - Utils::set_status('cdnRead', $result);
442 452
443 453 $this->deleteSingle($object_key);
444 454 return [
445 455 'message' => $result['message'],
@@ -448,17 +458,14 @@
448 458 'lastChecked' => $result['lastChecked'],
449 459 ];
450 460 } catch (AwsException $ex) {
451 461 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
452 - Utils::set_status('cdnRead', $result);
453 462 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => $result['lastChecked']];
454 463 } catch (S3Exception $ex) {
455 464 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
456 - Utils::set_status('cdnRead', $result);
457 465 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => $result['lastChecked']];
458 466 } catch (Exception $ex) {
459 467 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
460 - Utils::set_status('cdnRead', $result);
461 468 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => $result['lastChecked'] ];
462 469 }
463 470 }
464 471
@@ -509,8 +516,9 @@
509 516 *
510 517 */
511 518 public function toPrivate($key) {
512 519 if(!$key) return false;
520 + if(!$this->S3CompatibleClient) return false;
513 521 try {
514 522 $this->S3CompatibleClient->putObjectAcl([
515 523 'Bucket' => $this->bucket_name,
516 524 'Key' => $key,
@@ -519,9 +527,8 @@
519 527 return true;
520 528 } catch (AwsException $ex) {
521 529 return false;
522 530 }
523 - return false;
524 531 }
525 532
526 533
527 534
@@ -527,23 +534,23 @@
527 534
528 535 /**
529 536 * Make Object Public
530 537 * @since 1.0.0
531 - *
538 + *
532 539 */
533 540 public function toPublic($key) {
534 541 if(!$key) return false;
542 + if(!$this->S3CompatibleClient) return false;
535 543 try {
536 544 $this->S3CompatibleClient->putObjectAcl([
537 545 'Bucket' => $this->bucket_name,
538 546 'Key' => $key,
539 547 'ACL' => 'public-read'
540 - ]);
548 + ]);
541 549 return true;
542 550 } catch (AwsException $ex) {
543 551 return false;
544 552 }
545 - return false;
546 553 }
547 554
548 555
549 556
@@ -559,8 +566,9 @@
559 566 $client = $client ?? $this->S3CompatibleClient;
560 567 if($client->doesObjectExistV2($bucket_name, $key)) {
561 568 return true;
562 569 }
570 + return false;
563 571 } catch (AwsException $ex) {
564 572 return false;
565 573 } catch (S3Exception $ex) {
566 574 return false;
@@ -569,14 +577,65 @@
569 577 }
570 578 }
571 579
572 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 + /**
573 633 * Upload Single
574 634 * @since 1.0.0
575 635 * @return boolean
576 636 */
577 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
578 - $result = array();
637 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
579 638 if (
580 639 isset($absolute_source_path) && !empty($absolute_source_path) &&
581 640 isset($relative_source_path) && !empty($relative_source_path)
582 641 ) {
@@ -581,93 +640,122 @@
581 640 isset($relative_source_path) && !empty($relative_source_path)
582 641 ) {
583 642 $file_name = wp_basename( $relative_source_path );
584 643 if ($file_name) {
585 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
586 -
587 - // Decide Multipart upload or normal put object
588 - if (filesize($absolute_source_path) <= Schema::getConstant('S3COMPATIBLE_MULTIPART_MIN_FILE_SIZE')) {
589 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
590 - try {
591 - $handle = fopen($absolute_source_path, 'rb');
592 - $upload = $this->S3CompatibleClient->putObject([
593 - 'Bucket' => $this->bucket_name,
594 - 'Key' => $upload_path,
595 - 'Body' => $handle
596 - ]);
597 - if (is_resource($handle)) {
598 - fclose($handle);
599 - }
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 + }
600 666
601 - $result = array(
602 - 'success' => true,
603 - 'code' => 200,
604 - 'file_url' => $this->generate_file_url($upload_path),
605 - 'key' => $upload_path,
606 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
607 - );
608 - } catch (AwsException $e) {
609 - $result = array(
610 - 'success' => false,
611 - 'code' => 200,
612 - 'message' => $e->getMessage()
613 - );
614 - }
615 - } else {
616 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $absolute_source_path, [
617 - 'bucket' => $this->bucket_name,
618 - 'key' => $upload_path
619 - ]);
620 -
621 - try {
622 - do {
623 - try {
624 - $uploaded = $multiUploader->upload();
625 - } catch (MultipartUploadException $e) {
626 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $absolute_source_path, [
627 - 'state' => $e->getState(),
628 - ]);
629 - }
630 - } 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 + }
631 674
632 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
633 - $result = array(
634 - 'success' => true,
635 - 'code' => 200,
636 - 'file_url' => $this->generate_file_url($upload_path),
637 - 'key' => $upload_path,
638 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
639 - );
640 - } else {
641 - $result = array(
642 - 'success' => false,
643 - 'code' => 200,
644 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
645 - );
646 - }
647 - } catch (MultipartUploadException $e) {
648 - $result = array(
649 - 'success' => false,
650 - 'code' => 200,
651 - 'message' => $e->getMessage()
652 - );
653 - }
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 + ];
654 742 }
655 - } else {
656 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
657 746 'success' => false,
658 747 'code' => 200,
659 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
660 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
661 756 }
662 - } else {
663 - $result = array(
664 - 'success' => false,
665 - 'code' => 200,
666 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
667 - );
668 757 }
669 - return $result;
670 758 }
671 759
672 760 /**
673 761 * Save object to server
@@ -673,8 +761,9 @@
673 761 * Save object to server
674 762 * @since 1.0.0
675 763 */
676 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
677 766 try {
678 767 $getObject = $this->S3CompatibleClient->GetObject([
679 768 'Bucket' => $this->bucket_name,
680 769 'Key' => $key,
@@ -688,51 +777,156 @@
688 777 }
689 778 return false;
690 779 }
691 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 + }
692 798
693 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 + /**
694 859 * Copy to new path
695 860 * @since 1.3.4
696 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.
697 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 + }
698 875 try {
699 - // Step 1: Verify object exists at old location
700 - if (!$this->exists($key)) {
701 - return [
702 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
703 - 'code' => 200,
704 - 'success' => false
705 - ];
706 - }
707 - // Step 2: Copy object
708 - if (!$this->exists($new_path)) {
709 - $this->S3CompatibleClient->copyObject([
710 - 'Bucket' => $this->bucket_name,
711 - 'CopySource' => "{$this->bucket_name}/{$key}",
712 - 'Key' => $new_path,
713 - 'MetadataDirective' => 'COPY',
714 - ]);
715 - }
716 - // Step 3: Verify object exists at new location
717 - if ($this->exists($new_path)) {
718 - return [
719 - 'success' => true,
720 - 'code' => 200,
721 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
722 - ];
723 - }
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 + ];
724 887 } catch (AwsException $e) {
725 - return false;
888 + return [
889 + 'success' => false,
890 + 'code' => 200,
891 + 'message' => $e->getMessage()
892 + ];
726 893 }
727 - return [
728 - 'success' => false,
729 - 'code' => 200,
730 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
731 - ];
732 894 }
733 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 + }
734 927
928 +
735 929 /**
736 930 * Delete Single
737 931 * @since 1.0.0
738 932 * @return boolean
@@ -738,8 +932,15 @@
738 932 * @return boolean
739 933 */
740 934 public function deleteSingle($key) {
741 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 + }
742 943 if (isset($key) && !empty($key)) {
743 944 try {
744 945 $this->S3CompatibleClient->deleteObject([
745 946 'Bucket' => $this->bucket_name,
@@ -782,8 +983,15 @@
782 983 * @return boolean
783 984 */
784 985 public function get_private_url($key) {
785 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 + }
786 994 if (isset($key) && !empty($key)) {
787 995 try {
788 996 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
789 997 'Bucket' => $this->bucket_name,