PluginProbe
Media Cloud Sync / trunk
Media Cloud Sync vtrunk
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 1.3.0 All 34 releases
← All changes | includes/base/services/docean.php +452 -152 1.2.12trunk 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 DOcean {
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'])
@@ -86,14 +91,13 @@
86 91 * Verify Credentials
87 92 * @since 1.0.0
88 93 * @return boolean
89 94 */
90 - public function verifyCredentials($access_key, $secret_key, $region){
91 - if (
92 - isset($region) && !empty($region) &&
93 - isset($access_key) && !empty($access_key) &&
94 - isset($secret_key) && !empty($secret_key)
95 - ) {
95 + public function verifyCredentials($config = []) {
96 + $region = isset($config['region']) ? $config['region'] : '';
97 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
98 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
99 + if (!Service::has_missing_fields([$region, $access_key, $secret_key])) {
96 100 try {
97 101 $endpoint = $this->get_domain($region);
98 102
99 103 $DOClient = new S3Client([
@@ -107,9 +111,9 @@
107 111 'key' => $access_key,
108 112 'secret' => $secret_key,
109 113 ],
110 114 ]);
111 -
115 +
112 116 $result = [
113 117 'success' => false,
114 118 'code' => 200,
115 119 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -150,11 +154,30 @@
150 154
151 155 if($result['success'] == false) {
152 156 return $result;
153 157 }
154 - $result['buckets_data']['buckets'] = [];
155 - $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
156 - $result['buckets_data']['status'] = true;
158 + try {
159 + $buckets = $DOClient->listBuckets();
160 + $newBucketFormat = [];
161 + if(isset($buckets['Buckets']) && !empty($buckets['Buckets'])){
162 + foreach($buckets['Buckets'] as $bucket) {
163 + if(isset($bucket['Name'])) {
164 + $newBucketFormat[] = ['Name' => $bucket['Name'], 'CreationDate' => $bucket['CreationDate'] ?? ''];
165 + }
166 + }
167 + }
168 + $result['buckets_data']['buckets'] = $newBucketFormat;
169 + $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
170 + $result['buckets_data']['status'] = true;
171 + } catch (S3Exception $e) {
172 + $result ['buckets_data']['buckets'] = [];
173 + $result ['buckets_data']['message'] = esc_html__('Unable to list buckets, Please check the bucket listing permission', 'media-cloud-sync');
174 + $result ['buckets_data']['status'] = false;
175 + } catch (Exception $e) {
176 + $result ['buckets_data']['buckets'] = [];
177 + $result ['buckets_data']['message'] = esc_html__('Unable to list buckets, Please check the bucket listing permission', 'media-cloud-sync');
178 + $result ['buckets_data']['status'] = false;
179 + }
157 180 return $result;
158 181 } catch (S3Exception $ex) {
159 182 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
160 183 } catch (Exception $ex) {
@@ -168,15 +191,15 @@
168 191 * Verify Bucket
169 192 * @since 1.0.0
170 193 * @return boolean
171 194 */
172 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name){
173 - if (
174 - isset($region) && !empty($region) &&
175 - isset($access_key) && !empty($access_key) &&
176 - isset($secret_key) && !empty($secret_key) &&
177 - isset($bucket_name) && !empty($bucket_name)
178 - ) {
195 + public function verifyBucketExist( $config = [], $bucketConfig = [] ) {
196 + $region = isset($config['region']) ? $config['region'] : '';
197 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
198 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
199 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
200 +
201 + if (!Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
179 202 try {
180 203 $endpoint = $this->get_domain($region);
181 204
182 205 $DOClient = new S3Client([
@@ -226,10 +249,15 @@
226 249 * Create Bucket
227 250 * @since 1.0.0
228 251 * @return boolean
229 252 */
230 - public function createBucket($access_key, $secret_key, $region, $bucket_name){
231 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
253 + public function createBucket( $config = [], $bucketConfig = [] ) {
254 + $region = isset($config['region']) ? $config['region'] : '';
255 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
256 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
257 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
258 +
259 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
232 260 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
233 261 }
234 262
235 263 try {
@@ -343,10 +371,15 @@
343 371 /**
344 372 * Check Bucket Write Permission
345 373 * @since 1.0.0
346 374 */
347 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name){
348 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
375 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
376 + $region = isset($config['region']) ? $config['region'] : '';
377 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
378 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
379 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
380 +
381 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
349 382 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
350 383 }
351 384
352 385 try {
@@ -364,9 +397,9 @@
364 397 'secret' => $secret_key,
365 398 ],
366 399 ]);
367 400
368 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
401 + $object_key = Utils::get_permission_check_object_key();
369 402
370 403
371 404 // Create a dummy object to check write permission
372 405 $DOClient->putObject([
@@ -374,9 +407,9 @@
374 407 'Key' => $object_key,
375 408 'Body' => 'This is a test object to check write permission.',
376 409 ]);
377 410 // Check if the object was created successfully
378 - if ($DOClient->doesObjectExist($bucket_name, $object_key)) {
411 + if ($this->exists($object_key, $bucket_name, $DOClient)) {
379 412 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
380 413 } else {
381 414 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
382 415 }
@@ -395,10 +428,15 @@
395 428 /**
396 429 * Check Bucket Delete Permission
397 430 * @since 1.0.0
398 431 */
399 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name){
400 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
432 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
433 + $region = isset($config['region']) ? $config['region'] : '';
434 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
435 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
436 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
437 +
438 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
401 439 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
402 440 }
403 441
404 442 try {
@@ -416,9 +454,9 @@
416 454 'secret' => $secret_key,
417 455 ],
418 456 ]);
419 457
420 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
458 + $object_key = Utils::get_permission_check_object_key();
421 459
422 460 // Create a dummy object to check dlete permission
423 461 $DOClient->deleteObject([
424 462 'Bucket' => $bucket_name,
@@ -425,9 +463,9 @@
425 463 'Key' => $object_key,
426 464 ]);
427 465
428 466 // Check if the object was created successfully
429 - if (!$DOClient->doesObjectExist($bucket_name, $object_key)) {
467 + if (!$this->exists($object_key, $bucket_name, $DOClient)) {
430 468 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
431 469 } else {
432 470 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
433 471 }
@@ -446,49 +484,58 @@
446 484 * Check Bucket Read Permission
447 485 * @since 1.2.4
448 486 */
449 487 public function verifyObjectReadPermission() {
450 - if (empty($this->DOClient) || empty($this->bucket_name)) {
451 - return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false];
488 + $result = [
489 + 'status' => false,
490 + 'message' => '',
491 + 'lastChecked' => time(),
492 + ];
493 + if (Service::has_missing_fields([$this->DOClient, $this->bucket_name])) {
494 + $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
495 + return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
452 496 }
453 497
454 498 try {
455 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
499 + $object_key = Utils::get_permission_check_object_key();
456 500
457 501 // Check if the object was created successfully
458 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $object_key)) {
502 + if (!$this->exists($object_key)) {
459 503 // Create a dummy object to check write permission
460 504 $this->DOClient->putObject([
461 505 'Bucket' => $this->bucket_name,
462 506 'Key' => $object_key,
463 507 'Body' => 'This is a test object to check permission.',
508 + 'ContentType' => 'text/plain',
509 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
464 510 ]);
465 - }
466 -
511 + }
467 512
513 +
468 514 $url = $this->generate_file_url($object_key);
469 515 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
470 - $headers = @get_headers($cdn_url);
471 - $result = [
472 - 'status' => false,
473 - 'message' => '',
474 - 'lastChecked' => time(),
475 - ];
476 - if (strpos($headers[0], '200') !== false) {
516 + // Never trust a cached response for this fixed, predictable URL — a stale cached
517 + // error would otherwise keep failing the check long after real access is fine.
518 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
519 + $headers = @get_headers($cdn_url, false, $no_cache_context);
520 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
521 + ? (int) $matches[1]
522 + : 0;
523 +
524 + if ($status_code === 200) {
477 525 $result['status'] = true;
478 526 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
479 - } else if (strpos($headers[0], '403') !== false) {
527 + } else if ($status_code === 403) {
480 528 $result['status'] = false;
481 - if($this->cdnConfig['service'] == $this->service) {
529 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
482 530 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
483 531 } else {
484 532 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
485 533 }
486 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
487 - } else if (strpos($headers[0], '404') !== false) {
534 + } else if ($status_code === 404) {
488 535 $result['status'] = false;
489 536 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
490 - } else if (strpos($headers[0], '500') !== false) {
537 + } else if ($status_code === 500) {
491 538 $result['status'] = false;
492 539 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
493 540 } else {
494 541 $result['status'] = false;
@@ -493,9 +540,8 @@
493 540 } else {
494 541 $result['status'] = false;
495 542 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
496 543 }
497 - Utils::set_status('cdnRead', $result);
498 544
499 545 $this->deleteSingle($object_key);
500 546 return [
501 547 'message' => $result['message'],
@@ -503,13 +549,16 @@
503 549 'success' => $result['status'],
504 550 'lastChecked' => $result['lastChecked'],
505 551 ];
506 552 } catch (AwsException $ex) {
507 - return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
553 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
554 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
508 555 } catch (S3Exception $ex) {
509 - return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
556 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
557 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
510 558 } catch (Exception $ex) {
511 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
559 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
560 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
512 561 }
513 562 }
514 563
515 564 /**
@@ -518,17 +567,17 @@
518 567 */
519 568 public function isConfigured(){
520 569 if ($this->DOClient) {
521 570 try {
522 - $DOClient->listObjectsV2([
571 + $this->DOClient->listObjectsV2([
523 572 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
524 573 ]);
525 -
574 +
526 575 // If we reach here, the credentials are valid
527 576 return true;
528 577 } catch (AwsException $e) {
529 578 $code = $e->getAwsErrorCode();
530 -
579 +
531 580 $validErrors = [
532 581 'AccessDenied',
533 582 'NoSuchBucket',
534 583 'AllAccessDisabled',
@@ -535,12 +584,12 @@
535 584 'AuthorizationHeaderMalformed',
536 585 'PermanentRedirect',
537 586 'InvalidBucketName',
538 587 ];
539 -
588 +
540 589 if (in_array($code, $validErrors)) {
541 590 // If we reach here, the credentials are valid
542 - return false;
591 + return true;
543 592 } else {
544 593 // If we reach here, the credentials are not valid
545 594 return false;
546 595 }
@@ -555,8 +604,9 @@
555 604 *
556 605 */
557 606 public function toPrivate($key) {
558 607 if(!$key) return false;
608 + if(!$this->DOClient) return false;
559 609 try {
560 610 $this->DOClient->putObjectAcl([
561 611 'Bucket' => $this->bucket_name,
562 612 'Key' => $key,
@@ -565,9 +615,8 @@
565 615 return true;
566 616 } catch (AwsException $ex) {
567 617 return false;
568 618 }
569 - return false;
570 619 }
571 620
572 621
573 622
@@ -573,23 +622,23 @@
573 622
574 623 /**
575 624 * Make Object Public
576 625 * @since 1.0.0
577 - *
626 + *
578 627 */
579 628 public function toPublic($key) {
580 629 if(!$key) return false;
630 + if(!$this->DOClient) return false;
581 631 try {
582 632 $this->DOClient->putObjectAcl([
583 633 'Bucket' => $this->bucket_name,
584 634 'Key' => $key,
585 635 'ACL' => 'public-read'
586 - ]);
636 + ]);
587 637 return true;
588 638 } catch (AwsException $ex) {
589 639 return false;
590 640 }
591 - return false;
592 641 }
593 642
594 643
595 644
@@ -596,15 +645,18 @@
596 645 /**
597 646 * Check the object exist
598 647 * @since 1.1.8
599 648 */
600 - public function exists($key) {
649 + public function exists($key, $bucket_name = '', $client = null) {
601 650 if(!$key) return false;
602 651
603 652 try {
604 - if($this->DOClient->doesObjectExist($this->bucket_name, $key)) {
653 + $client = $client ?? $this->DOClient;
654 + $bucket_name = !empty($bucket_name) ? $bucket_name : $this->bucket_name;
655 + if($client->doesObjectExistV2($bucket_name, $key)) {
605 656 return true;
606 657 }
658 + return false;
607 659 } catch (AwsException $ex) {
608 660 return false;
609 661 }
610 662 catch (S3Exception $ex) {
@@ -614,101 +666,180 @@
614 666 }
615 667 }
616 668
617 669 /**
670 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
671 + * @since 1.3.13
672 + */
673 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
674 + if (!$this->DOClient) {
675 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
676 + }
677 + try {
678 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
679 + if (!empty($delimiter)) {
680 + $params['Delimiter'] = $delimiter;
681 + }
682 + if (!empty($prefix)) {
683 + $params['Prefix'] = $prefix;
684 + }
685 + if (!empty($continuationToken)) {
686 + $params['ContinuationToken'] = $continuationToken;
687 + }
688 +
689 + $result = $this->DOClient->listObjectsV2($params);
690 + $folders = [];
691 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
692 + $folders[] = $common['Prefix'];
693 + }
694 + $objects = [];
695 + foreach (($result['Contents'] ?? []) as $object) {
696 + if ($object['Key'] === $prefix) {
697 + continue; // the folder placeholder object itself, not a file
698 + }
699 + $objects[] = [
700 + 'key' => $object['Key'],
701 + 'size' => (int) $object['Size'],
702 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
703 + ];
704 + }
705 +
706 + return [
707 + 'success' => true,
708 + 'code' => 200,
709 + 'message' => '',
710 + 'folders' => $folders,
711 + 'objects' => $objects,
712 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
713 + ];
714 + } catch (AwsException $e) {
715 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
716 + } catch (S3Exception $e) {
717 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
718 + } catch (Exception $e) {
719 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
720 + }
721 + }
722 +
723 + /**
618 724 * Upload Single
619 725 * @since 1.0.0
620 726 * @return boolean
621 727 */
622 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
623 - $result = array();
728 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
624 729 if (
625 - isset($media_absolute_path) && !empty($media_absolute_path) &&
626 - isset($media_path) && !empty($media_path)
730 + isset($absolute_source_path) && !empty($absolute_source_path) &&
731 + isset($relative_source_path) && !empty($relative_source_path)
627 732 ) {
628 - $file_name = wp_basename( $media_path );
733 + $file_name = wp_basename( $relative_source_path );
629 734 if ($file_name) {
630 - $upload_path = Utils::generate_object_key($media_path, $prefix);
631 -
632 - // Decide Multipart upload or normal put object
633 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
634 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
635 - try {
636 - $upload = $this->DOClient->putObject([
637 - 'Bucket' => $this->bucket_name,
638 - 'Key' => $upload_path,
639 - 'Body' => fopen($media_absolute_path, 'r'),
640 - ]);
735 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
736 + return $this->execute_upload($absolute_source_path, $upload_path);
737 + }
738 + return [
739 + 'success' => false,
740 + 'code' => 200,
741 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
742 + ];
743 + }
744 + return [
745 + 'success' => false,
746 + 'code' => 200,
747 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
748 + ];
749 + }
641 750
642 - $result = array(
643 - 'success' => true,
644 - 'code' => 200,
645 - 'file_url' => $this->generate_file_url($upload_path),
646 - 'key' => $upload_path,
647 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
648 - );
649 - } catch (AwsException $e) {
650 - $result = array(
651 - 'success' => false,
652 - 'code' => 200,
653 - 'message' => $e->getMessage()
654 - );
655 - }
656 - } else {
657 - $multiUploader = new MultipartUploader($this->DOClient, $media_absolute_path, [
658 - 'bucket' => $this->bucket_name,
659 - 'key' => $upload_path,
660 - ]);
661 -
662 - try {
663 - do {
664 - try {
665 - $uploaded = $multiUploader->upload();
666 - } catch (MultipartUploadException $e) {
667 - $multiUploader = new MultipartUploader($this->DOClient, $media_absolute_path, [
668 - 'state' => $e->getState(),
669 - ]);
670 - }
671 - } while (!isset($uploaded));
751 + /**
752 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
753 + * @since 1.4.0
754 + */
755 + public function uploadObjectAtKey($absolute_source_path, $key) {
756 + return $this->execute_upload($absolute_source_path, $key);
757 + }
672 758
673 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
674 - $result = array(
675 - 'success' => true,
676 - 'code' => 200,
677 - 'file_url' => $this->generate_file_url($upload_path),
678 - 'key' => $upload_path,
679 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
680 - );
681 - } else {
682 - $result = array(
683 - 'success' => false,
684 - 'code' => 200,
685 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
686 - );
687 - }
688 - } catch (MultipartUploadException $e) {
689 - $result = array(
690 - 'success' => false,
691 - 'code' => 200,
692 - 'message' => $e->getMessage()
693 - );
694 - }
759 + /**
760 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
761 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
762 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
763 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
764 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
765 + * previously-failed multipart attempt).
766 + * @since 1.4.0
767 + */
768 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
769 + $handle = fopen($absolute_source_path, 'rb');
770 + $params = [];
771 + $cache_control = Utils::get_cache_control_header();
772 + if ($cache_control) {
773 + $params['CacheControl'] = $cache_control;
774 + }
775 + $options += [
776 + 'mup_threshold' => Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE'),
777 + 'params' => $params,
778 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
779 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
780 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
781 + ];
782 + return new ObjectUploader($this->DOClient, $this->bucket_name, $key, $handle, null, $options);
783 + }
784 +
785 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
786 + // return value, relying on the same Command object being modified.
787 + private function strip_acl($params) {
788 + if ($params instanceof Command && $params->hasParam('ACL')) {
789 + unset($params['ACL']);
790 + } elseif (is_array($params) && isset($params['ACL'])) {
791 + unset($params['ACL']);
792 + }
793 + return $params;
794 + }
795 +
796 + /**
797 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
798 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
799 + * state rather than restarting the whole upload — same retry contract uploadSingle()
800 + * had before the ObjectUploader swap.
801 + * @since 1.4.0
802 + */
803 + private function execute_upload($absolute_source_path, $key) {
804 + $max_attempts = 3;
805 + $attempt = 0;
806 + $options = [];
807 +
808 + while (true) {
809 + $attempt++;
810 + try {
811 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
812 + return [
813 + 'success' => true,
814 + 'code' => 200,
815 + 'file_url' => $this->generate_file_url($key),
816 + 'key' => $key,
817 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
818 + ];
819 + } catch (MultipartUploadException $e) {
820 + if ($attempt >= $max_attempts) {
821 + return [
822 + 'success' => false,
823 + 'code' => 200,
824 + 'message' => $e->getMessage()
825 + ];
695 826 }
696 - } else {
697 - $result = array(
827 + $options = ['state' => $e->getState()];
828 + } catch (AwsException $e) {
829 + return [
698 830 'success' => false,
699 831 'code' => 200,
700 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
701 - );
832 + 'message' => $e->getMessage()
833 + ];
834 + } catch (Exception $e) {
835 + return [
836 + 'success' => false,
837 + 'code' => 200,
838 + 'message' => $e->getMessage()
839 + ];
702 840 }
703 - } else {
704 - $result = array(
705 - 'success' => false,
706 - 'code' => 200,
707 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
708 - );
709 841 }
710 - return $result;
711 842 }
712 843
713 844 /**
714 845 * Save object to server
@@ -714,8 +845,9 @@
714 845 * Save object to server
715 846 * @since 1.0.0
716 847 */
717 848 public function object_to_server($key, $save_path) {
849 + if(!$this->DOClient) return false;
718 850 try {
719 851 $getObject = $this->DOClient->GetObject([
720 852 'Bucket' => $this->bucket_name,
721 853 'Key' => $key,
@@ -729,10 +861,155 @@
729 861 }
730 862 return false;
731 863 }
732 864
865 + /**
866 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
867 + * the content itself rather than a copy on the server's filesystem.
868 + * @since 1.3.13
869 + */
870 + public function get_object_content($key) {
871 + if(!$this->DOClient) return false;
872 + try {
873 + $result = $this->DOClient->GetObject([
874 + 'Bucket' => $this->bucket_name,
875 + 'Key' => $key,
876 + ]);
877 + return (string) $result['Body'];
878 + } catch (AwsException $e) {
879 + return false;
880 + }
881 + }
733 882
734 883 /**
884 + * Deletes the live object, then best-effort purges every historical version too — a
885 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
886 + * versions (and the storage they use) behind at the old key. The live delete happens
887 + * unconditionally first: DigitalOcean Spaces doesn't support object versioning at all,
888 + * so the version-listing part below simply fails there (caught, non-fatal) — the object
889 + * must still end up gone either way, which is why it can't be the only delete call.
890 + * @since 1.3.14
891 + */
892 + public function purge_all_versions($key) {
893 + if (!$this->DOClient) {
894 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
895 + }
896 +
897 + try {
898 + $this->DOClient->deleteObject([
899 + 'Bucket' => $this->bucket_name,
900 + 'Key' => $key,
901 + ]);
902 + } catch (AwsException $e) {
903 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
904 + }
905 +
906 + // Best-effort only from here — Spaces doesn't support version listing at all, so
907 + // this always no-ops there; the live object above is already gone regardless.
908 + try {
909 + $objects = [];
910 + $marker = null;
911 + do {
912 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
913 + if ($marker) {
914 + $args['KeyMarker'] = $marker['key'];
915 + $args['VersionIdMarker'] = $marker['version'];
916 + }
917 + $result = $this->DOClient->listObjectVersions($args);
918 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
919 + if (($version['Key'] ?? null) === $key) {
920 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
921 + }
922 + }
923 + $marker = !empty($result['IsTruncated'])
924 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
925 + : null;
926 + } while ($marker);
927 +
928 + foreach (array_chunk($objects, 1000) as $chunk) {
929 + $this->DOClient->deleteObjects([
930 + 'Bucket' => $this->bucket_name,
931 + 'Delete' => ['Objects' => $chunk],
932 + ]);
933 + }
934 + } catch (AwsException $e) {
935 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
936 + }
937 +
938 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
939 + }
940 +
941 + /**
942 + * Copy to new path
943 + * @since 1.3.4
944 + */
945 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
946 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
947 + // processing keys sequentially, three extra round-trips per file adds up fast on a
948 + // folder with many files. copyObject() itself throws (caught below) if the source is
949 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
950 + public function copy_to_new_path($key, $new_path) {
951 + if (!$this->DOClient) {
952 + return [
953 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
954 + 'code' => 200,
955 + 'success' => false
956 + ];
957 + }
958 + try {
959 + $this->DOClient->copyObject([
960 + 'Bucket' => $this->bucket_name,
961 + 'CopySource' => "{$this->bucket_name}/{$key}",
962 + 'Key' => $new_path,
963 + 'MetadataDirective' => 'COPY',
964 + ]);
965 + return [
966 + 'success' => true,
967 + 'code' => 200,
968 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
969 + ];
970 + } catch (AwsException $e) {
971 + return [
972 + 'success' => false,
973 + 'code' => 200,
974 + 'message' => $e->getMessage()
975 + ];
976 + }
977 + }
978 +
979 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
980 + // access there too, so callers should fall back to download+upload on failure.
981 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
982 + if (!$this->DOClient) {
983 + return [
984 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
985 + 'code' => 200,
986 + 'success' => false
987 + ];
988 + }
989 + try {
990 + $this->DOClient->copyObject([
991 + 'Bucket' => $dest_bucket,
992 + 'CopySource' => "{$this->bucket_name}/{$key}",
993 + 'Key' => $new_key,
994 + 'MetadataDirective' => 'COPY',
995 + ]);
996 + return [
997 + 'success' => true,
998 + 'code' => 200,
999 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1000 + ];
1001 + } catch (AwsException $e) {
1002 + return [
1003 + 'success' => false,
1004 + 'code' => 200,
1005 + 'message' => $e->getMessage()
1006 + ];
1007 + }
1008 + }
1009 +
1010 +
1011 + /**
735 1012 * Delete Single
736 1013 * @since 1.0.0
737 1014 * @return boolean
738 1015 */
@@ -737,8 +1014,15 @@
737 1014 * @return boolean
738 1015 */
739 1016 public function deleteSingle($key) {
740 1017 $result = array();
1018 + if (!$this->DOClient) {
1019 + return array(
1020 + 'success' => false,
1021 + 'code' => 200,
1022 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1023 + );
1024 + }
741 1025 if (isset($key) && !empty($key)) {
742 1026 try {
743 1027 $this->DOClient->deleteObject([
744 1028 'Bucket' => $this->bucket_name,
@@ -744,9 +1028,9 @@
744 1028 'Bucket' => $this->bucket_name,
745 1029 'Key' => $key
746 1030 ]);
747 1031
748 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $key)) {
1032 + if (!$this->exists($key)) {
749 1033 $result = array(
750 1034 'success' => true,
751 1035 'code' => 200,
752 1036 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -775,14 +1059,21 @@
775 1059 return $result;
776 1060 }
777 1061
778 1062 /**
779 - * get presigned URL
1063 + * get private URL
780 1064 * @since 1.0.0
781 1065 * @return boolean
782 1066 */
783 - public function get_presigned_url($key) {
1067 + public function get_private_url($key) {
784 1068 $result = array();
1069 + if (!$this->DOClient) {
1070 + return array(
1071 + 'success' => false,
1072 + 'code' => 200,
1073 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1074 + );
1075 + }
785 1076 if (isset($key) && !empty($key)) {
786 1077 try {
787 1078 $cmd = $this->DOClient->getCommand('GetObject', [
788 1079 'Bucket' => $this->bucket_name,
@@ -788,24 +1079,24 @@
788 1079 'Bucket' => $this->bucket_name,
789 1080 'Key' => $key
790 1081 ]);
791 1082
792 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1083 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
793 1084
794 1085 $request = $this->DOClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
795 1086
796 - if ($presignedUrl = (string)$request->getUri()) {
1087 + if ($privateUrl = (string)$request->getUri()) {
797 1088 $result = array(
798 1089 'success' => true,
799 1090 'code' => 200,
800 - 'file_url' => $presignedUrl,
801 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1091 + 'file_url' => $privateUrl,
1092 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
802 1093 );
803 1094 } else {
804 1095 $result = array(
805 1096 'success' => false,
806 1097 'code' => 200,
807 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1098 + 'message' => esc_html__('Error getting Private URL', 'media-cloud-sync')
808 1099 );
809 1100 }
810 1101 } catch (AwsException $e) {
811 1102 $result = array(
@@ -826,9 +1117,9 @@
826 1117
827 1118 /**
828 1119 * Generate file URL
829 1120 */
830 - private function generate_file_url($key){
1121 + public function generate_file_url($key){
831 1122 $domain = $this->get_domain();
832 1123
833 1124 return apply_filters('wpmcs_generate_do_file_url',
834 1125 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -835,8 +1126,17 @@
835 1126 $domain,
836 1127 $this->bucket_name,
837 1128 $key
838 1129 );
1130 + }
1131 +
1132 + /**
1133 + * Is Provider URL
1134 + * @since 1.3.6
1135 + */
1136 + public function is_provider_url($url) {
1137 + $domain = $this->get_domain();
1138 + return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
839 1139 }
840 1140
841 1141 /**
842 1142 * Get domain URL