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/docean.php +414 -173 1.3.111.4.1 View file →
@@ -8,8 +8,10 @@
8 8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 +use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 +use Dudlewebs\WPMCS\s3\Aws\Command;
12 14 use Exception;
13 15
14 16 class 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'])
@@ -90,9 +95,9 @@
90 95 public function verifyCredentials($config = []) {
91 96 $region = isset($config['region']) ? $config['region'] : '';
92 97 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
93 98 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
94 - if (!empty($region) && !empty($access_key) && !empty($secret_key)) {
99 + if (!Service::has_missing_fields([$region, $access_key, $secret_key])) {
95 100 try {
96 101 $endpoint = $this->get_domain($region);
97 102
98 103 $DOClient = new S3Client([
@@ -106,9 +111,9 @@
106 111 'key' => $access_key,
107 112 'secret' => $secret_key,
108 113 ],
109 114 ]);
110 -
115 +
111 116 $result = [
112 117 'success' => false,
113 118 'code' => 200,
114 119 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -192,9 +197,9 @@
192 197 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
193 198 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
194 199 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
195 200
196 - if (!empty($region) && !empty($access_key) && !empty($secret_key) && !empty($bucket_name)) {
201 + if (!Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
197 202 try {
198 203 $endpoint = $this->get_domain($region);
199 204
200 205 $DOClient = new S3Client([
@@ -250,9 +255,9 @@
250 255 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
251 256 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
252 257 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
253 258
254 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
259 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
255 260 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
256 261 }
257 262
258 263 try {
@@ -306,10 +311,17 @@
306 311
307 312
308 313 /**
309 314 * Add Bucket Policy
315 + *
316 + * $private_prefix, when non-empty, carves that path out of the public
317 + * grant entirely — every action in the list, not just reads, so an
318 + * anonymous caller can't read, write, or delete anything under it. Same
319 + * NotResource approach as S3::putBucketPolicy() — Spaces' policy API is
320 + * S3-compatible, so the identical fix applies unchanged.
321 + * @since 1.0.0
310 322 */
311 - private function putBucketPolicy($bucket, $DOClient = false) {
323 + private function putBucketPolicy($bucket, $DOClient = false, $private_prefix = '') {
312 324 if($DOClient == false) {
313 325 $DOClient = $this->DOClient;
314 326 }
315 327
@@ -314,38 +326,45 @@
314 326 }
315 327
316 328 if(empty($bucket)) return false;
317 329
330 + $actions = [
331 + "s3:DeleteObjectTagging",
332 + "s3:ListBucketMultipartUploads",
333 + "s3:DeleteObjectVersion",
334 + "s3:ListBucket",
335 + "s3:DeleteObjectVersionTagging",
336 + "s3:GetBucketAcl",
337 + "s3:ListMultipartUploadParts",
338 + "s3:PutObject",
339 + "s3:GetObjectAcl",
340 + "s3:GetObject",
341 + "s3:AbortMultipartUpload",
342 + "s3:DeleteObject",
343 + "s3:GetBucketLocation",
344 + "s3:PutObjectAcl",
345 + "s3:putBucketOwnershipControls",
346 + "s3:putBucketPolicy"
347 + ];
348 +
349 + $statement = [
350 + "Effect" => "Allow",
351 + "Principal" => "*",
352 + "Action" => $actions,
353 + ];
354 +
355 + if (!empty($private_prefix)) {
356 + $statement["NotResource"] = ["arn:aws:s3:::$bucket/$private_prefix/*"];
357 + } else {
358 + $statement["Resource"] = [
359 + "arn:aws:s3:::$bucket/*",
360 + "arn:aws:s3:::$bucket"
361 + ];
362 + }
363 +
318 364 $policy = json_encode([
319 - "Version" => "2012-10-17",
320 - "Statement" => [
321 - [
322 - "Effect" => "Allow",
323 - "Principal" => "*",
324 - "Action" => [
325 - "s3:DeleteObjectTagging",
326 - "s3:ListBucketMultipartUploads",
327 - "s3:DeleteObjectVersion",
328 - "s3:ListBucket",
329 - "s3:DeleteObjectVersionTagging",
330 - "s3:GetBucketAcl",
331 - "s3:ListMultipartUploadParts",
332 - "s3:PutObject",
333 - "s3:GetObjectAcl",
334 - "s3:GetObject",
335 - "s3:AbortMultipartUpload",
336 - "s3:DeleteObject",
337 - "s3:GetBucketLocation",
338 - "s3:PutObjectAcl",
339 - "s3:putBucketOwnershipControls",
340 - "s3:putBucketPolicy"
341 - ],
342 - "Resource" => [
343 - "arn:aws:s3:::$bucket/*",
344 - "arn:aws:s3:::$bucket"
345 - ]
346 - ]
347 - ]
365 + "Version" => "2012-10-17",
366 + "Statement" => [$statement]
348 367 ]);
349 368
350 369 try {
351 370 // Add bucket policy
@@ -360,10 +379,27 @@
360 379 return false; // Handle general exceptions
361 380 }
362 381 }
363 382
383 + /**
384 + * Apply (or, with an empty $private_prefix, un-apply) the private-path
385 + * bucket policy carve-out.
386 + * @since 1.0.0
387 + */
388 + public function applyPrivatePathPolicy($private_prefix) {
389 + if (!$this->DOClient || empty($this->bucket_name)) {
390 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
391 + }
364 392
393 + $ok = $this->putBucketPolicy($this->bucket_name, $this->DOClient, $private_prefix);
365 394
395 + return $ok
396 + ? ['success' => true, 'code' => 200, 'message' => esc_html__('Policy applied successfully', 'media-cloud-sync')]
397 + : ['success' => false, 'code' => 200, 'message' => esc_html__('Failed to apply bucket policy', 'media-cloud-sync')];
398 + }
399 +
400 +
401 +
366 402 /**
367 403 * Check Bucket Write Permission
368 404 * @since 1.0.0
369 405 */
@@ -372,9 +408,9 @@
372 408 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
373 409 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
374 410 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
375 411
376 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
412 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
377 413 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
378 414 }
379 415
380 416 try {
@@ -392,9 +428,9 @@
392 428 'secret' => $secret_key,
393 429 ],
394 430 ]);
395 431
396 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
432 + $object_key = Utils::get_permission_check_object_key();
397 433
398 434
399 435 // Create a dummy object to check write permission
400 436 $DOClient->putObject([
@@ -428,10 +464,10 @@
428 464 $region = isset($config['region']) ? $config['region'] : '';
429 465 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
430 466 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
431 467 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
432 -
433 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
468 +
469 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
434 470 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
435 471 }
436 472
437 473 try {
@@ -449,9 +485,9 @@
449 485 'secret' => $secret_key,
450 486 ],
451 487 ]);
452 488
453 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
489 + $object_key = Utils::get_permission_check_object_key();
454 490
455 491 // Create a dummy object to check dlete permission
456 492 $DOClient->deleteObject([
457 493 'Bucket' => $bucket_name,
@@ -484,15 +520,15 @@
484 520 'status' => false,
485 521 'message' => '',
486 522 'lastChecked' => time(),
487 523 ];
488 - if (empty($this->DOClient) || empty($this->bucket_name)) {
524 + if (Service::has_missing_fields([$this->DOClient, $this->bucket_name])) {
489 525 $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
490 526 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
491 527 }
492 528
493 529 try {
494 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
530 + $object_key = Utils::get_permission_check_object_key();
495 531
496 532 // Check if the object was created successfully
497 533 if (!$this->exists($object_key)) {
498 534 // Create a dummy object to check write permission
@@ -499,31 +535,38 @@
499 535 $this->DOClient->putObject([
500 536 'Bucket' => $this->bucket_name,
501 537 'Key' => $object_key,
502 538 'Body' => 'This is a test object to check permission.',
539 + 'ContentType' => 'text/plain',
540 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
503 541 ]);
504 - }
505 -
542 + }
506 543
544 +
507 545 $url = $this->generate_file_url($object_key);
508 546 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
509 - $headers = @get_headers($cdn_url);
547 + // Never trust a cached response for this fixed, predictable URL — a stale cached
548 + // error would otherwise keep failing the check long after real access is fine.
549 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
550 + $headers = @get_headers($cdn_url, false, $no_cache_context);
551 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
552 + ? (int) $matches[1]
553 + : 0;
510 554
511 - if (strpos($headers[0], '200') !== false) {
555 + if ($status_code === 200) {
512 556 $result['status'] = true;
513 557 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
514 - } else if (strpos($headers[0], '403') !== false) {
558 + } else if ($status_code === 403) {
515 559 $result['status'] = false;
516 - if($this->cdnConfig['service'] == $this->service) {
560 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
517 561 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
518 562 } else {
519 563 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
520 564 }
521 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
522 - } else if (strpos($headers[0], '404') !== false) {
565 + } else if ($status_code === 404) {
523 566 $result['status'] = false;
524 567 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
525 - } else if (strpos($headers[0], '500') !== false) {
568 + } else if ($status_code === 500) {
526 569 $result['status'] = false;
527 570 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
528 571 } else {
529 572 $result['status'] = false;
@@ -555,17 +598,17 @@
555 598 */
556 599 public function isConfigured(){
557 600 if ($this->DOClient) {
558 601 try {
559 - $DOClient->listObjectsV2([
602 + $this->DOClient->listObjectsV2([
560 603 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
561 604 ]);
562 -
605 +
563 606 // If we reach here, the credentials are valid
564 607 return true;
565 608 } catch (AwsException $e) {
566 609 $code = $e->getAwsErrorCode();
567 -
610 +
568 611 $validErrors = [
569 612 'AccessDenied',
570 613 'NoSuchBucket',
571 614 'AllAccessDisabled',
@@ -572,12 +615,12 @@
572 615 'AuthorizationHeaderMalformed',
573 616 'PermanentRedirect',
574 617 'InvalidBucketName',
575 618 ];
576 -
619 +
577 620 if (in_array($code, $validErrors)) {
578 621 // If we reach here, the credentials are valid
579 - return false;
622 + return true;
580 623 } else {
581 624 // If we reach here, the credentials are not valid
582 625 return false;
583 626 }
@@ -592,8 +635,9 @@
592 635 *
593 636 */
594 637 public function toPrivate($key) {
595 638 if(!$key) return false;
639 + if(!$this->DOClient) return false;
596 640 try {
597 641 $this->DOClient->putObjectAcl([
598 642 'Bucket' => $this->bucket_name,
599 643 'Key' => $key,
@@ -602,9 +646,8 @@
602 646 return true;
603 647 } catch (AwsException $ex) {
604 648 return false;
605 649 }
606 - return false;
607 650 }
608 651
609 652
610 653
@@ -610,23 +653,23 @@
610 653
611 654 /**
612 655 * Make Object Public
613 656 * @since 1.0.0
614 - *
657 + *
615 658 */
616 659 public function toPublic($key) {
617 660 if(!$key) return false;
661 + if(!$this->DOClient) return false;
618 662 try {
619 663 $this->DOClient->putObjectAcl([
620 664 'Bucket' => $this->bucket_name,
621 665 'Key' => $key,
622 666 'ACL' => 'public-read'
623 - ]);
667 + ]);
624 668 return true;
625 669 } catch (AwsException $ex) {
626 670 return false;
627 671 }
628 - return false;
629 672 }
630 673
631 674
632 675
@@ -642,8 +685,9 @@
642 685 $bucket_name = !empty($bucket_name) ? $bucket_name : $this->bucket_name;
643 686 if($client->doesObjectExistV2($bucket_name, $key)) {
644 687 return true;
645 688 }
689 + return false;
646 690 } catch (AwsException $ex) {
647 691 return false;
648 692 }
649 693 catch (S3Exception $ex) {
@@ -653,14 +697,67 @@
653 697 }
654 698 }
655 699
656 700 /**
701 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
702 + * @since 1.3.13
703 + */
704 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
705 + if (!$this->DOClient) {
706 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
707 + }
708 + try {
709 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
710 + if (!empty($delimiter)) {
711 + $params['Delimiter'] = $delimiter;
712 + }
713 + if (!empty($prefix)) {
714 + $params['Prefix'] = $prefix;
715 + }
716 + if (!empty($continuationToken)) {
717 + $params['ContinuationToken'] = $continuationToken;
718 + }
719 +
720 + $result = $this->DOClient->listObjectsV2($params);
721 + $folders = [];
722 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
723 + $folders[] = $common['Prefix'];
724 + }
725 + $objects = [];
726 + foreach (($result['Contents'] ?? []) as $object) {
727 + if ($object['Key'] === $prefix) {
728 + continue; // the folder placeholder object itself, not a file
729 + }
730 + $objects[] = [
731 + 'key' => $object['Key'],
732 + 'size' => (int) $object['Size'],
733 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
734 + ];
735 + }
736 +
737 + return [
738 + 'success' => true,
739 + 'code' => 200,
740 + 'message' => '',
741 + 'folders' => $folders,
742 + 'objects' => $objects,
743 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
744 + ];
745 + } catch (AwsException $e) {
746 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
747 + } catch (S3Exception $e) {
748 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
749 + } catch (Exception $e) {
750 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
751 + }
752 + }
753 +
754 + /**
657 755 * Upload Single
658 756 * @since 1.0.0
659 757 * @return boolean
660 758 */
661 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
662 - $result = array();
759 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
663 760 if (
664 761 isset($absolute_source_path) && !empty($absolute_source_path) &&
665 762 isset($relative_source_path) && !empty($relative_source_path)
666 763 ) {
@@ -665,93 +762,122 @@
665 762 isset($relative_source_path) && !empty($relative_source_path)
666 763 ) {
667 764 $file_name = wp_basename( $relative_source_path );
668 765 if ($file_name) {
669 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
670 -
671 - // Decide Multipart upload or normal put object
672 - if (filesize($absolute_source_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
673 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
674 - try {
675 - $handle = fopen($absolute_source_path, 'rb');
676 - $upload = $this->DOClient->putObject([
677 - 'Bucket' => $this->bucket_name,
678 - 'Key' => $upload_path,
679 - 'Body' => $handle,
680 - ]);
681 - if (is_resource($handle)) {
682 - fclose($handle);
683 - }
766 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
767 + if ($upload_path === false) {
768 + return [
769 + 'success' => false,
770 + 'code' => 200,
771 + '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')
772 + ];
773 + }
774 + return $this->execute_upload($absolute_source_path, $upload_path);
775 + }
776 + return [
777 + 'success' => false,
778 + 'code' => 200,
779 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
780 + ];
781 + }
782 + return [
783 + 'success' => false,
784 + 'code' => 200,
785 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
786 + ];
787 + }
684 788
685 - $result = array(
686 - 'success' => true,
687 - 'code' => 200,
688 - 'file_url' => $this->generate_file_url($upload_path),
689 - 'key' => $upload_path,
690 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
691 - );
692 - } catch (AwsException $e) {
693 - $result = array(
694 - 'success' => false,
695 - 'code' => 200,
696 - 'message' => $e->getMessage()
697 - );
698 - }
699 - } else {
700 - $multiUploader = new MultipartUploader($this->DOClient, $absolute_source_path, [
701 - 'bucket' => $this->bucket_name,
702 - 'key' => $upload_path,
703 - ]);
704 -
705 - try {
706 - do {
707 - try {
708 - $uploaded = $multiUploader->upload();
709 - } catch (MultipartUploadException $e) {
710 - $multiUploader = new MultipartUploader($this->DOClient, $absolute_source_path, [
711 - 'state' => $e->getState(),
712 - ]);
713 - }
714 - } while (!isset($uploaded));
789 + /**
790 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
791 + * @since 1.4.0
792 + */
793 + public function uploadObjectAtKey($absolute_source_path, $key) {
794 + return $this->execute_upload($absolute_source_path, $key);
795 + }
715 796
716 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
717 - $result = array(
718 - 'success' => true,
719 - 'code' => 200,
720 - 'file_url' => $this->generate_file_url($upload_path),
721 - 'key' => $upload_path,
722 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
723 - );
724 - } else {
725 - $result = array(
726 - 'success' => false,
727 - 'code' => 200,
728 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
729 - );
730 - }
731 - } catch (MultipartUploadException $e) {
732 - $result = array(
733 - 'success' => false,
734 - 'code' => 200,
735 - 'message' => $e->getMessage()
736 - );
737 - }
797 + /**
798 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
799 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
800 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
801 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
802 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
803 + * previously-failed multipart attempt).
804 + * @since 1.4.0
805 + */
806 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
807 + $handle = fopen($absolute_source_path, 'rb');
808 + $params = [];
809 + $cache_control = Utils::get_cache_control_header();
810 + if ($cache_control) {
811 + $params['CacheControl'] = $cache_control;
812 + }
813 + $options += [
814 + 'mup_threshold' => Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE'),
815 + 'params' => $params,
816 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
817 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
818 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
819 + ];
820 + return new ObjectUploader($this->DOClient, $this->bucket_name, $key, $handle, null, $options);
821 + }
822 +
823 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
824 + // return value, relying on the same Command object being modified.
825 + private function strip_acl($params) {
826 + if ($params instanceof Command && $params->hasParam('ACL')) {
827 + unset($params['ACL']);
828 + } elseif (is_array($params) && isset($params['ACL'])) {
829 + unset($params['ACL']);
830 + }
831 + return $params;
832 + }
833 +
834 + /**
835 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
836 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
837 + * state rather than restarting the whole upload — same retry contract uploadSingle()
838 + * had before the ObjectUploader swap.
839 + * @since 1.4.0
840 + */
841 + private function execute_upload($absolute_source_path, $key) {
842 + $max_attempts = 3;
843 + $attempt = 0;
844 + $options = [];
845 +
846 + while (true) {
847 + $attempt++;
848 + try {
849 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
850 + return [
851 + 'success' => true,
852 + 'code' => 200,
853 + 'file_url' => $this->generate_file_url($key),
854 + 'key' => $key,
855 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
856 + ];
857 + } catch (MultipartUploadException $e) {
858 + if ($attempt >= $max_attempts) {
859 + return [
860 + 'success' => false,
861 + 'code' => 200,
862 + 'message' => $e->getMessage()
863 + ];
738 864 }
739 - } else {
740 - $result = array(
865 + $options = ['state' => $e->getState()];
866 + } catch (AwsException $e) {
867 + return [
741 868 'success' => false,
742 869 'code' => 200,
743 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
744 - );
870 + 'message' => $e->getMessage()
871 + ];
872 + } catch (Exception $e) {
873 + return [
874 + 'success' => false,
875 + 'code' => 200,
876 + 'message' => $e->getMessage()
877 + ];
745 878 }
746 - } else {
747 - $result = array(
748 - 'success' => false,
749 - 'code' => 200,
750 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
751 - );
752 879 }
753 - return $result;
754 880 }
755 881
756 882 /**
757 883 * Save object to server
@@ -757,8 +883,9 @@
757 883 * Save object to server
758 884 * @since 1.0.0
759 885 */
760 886 public function object_to_server($key, $save_path) {
887 + if(!$this->DOClient) return false;
761 888 try {
762 889 $getObject = $this->DOClient->GetObject([
763 890 'Bucket' => $this->bucket_name,
764 891 'Key' => $key,
@@ -773,38 +900,112 @@
773 900 return false;
774 901 }
775 902
776 903 /**
904 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
905 + * the content itself rather than a copy on the server's filesystem.
906 + * @since 1.3.13
907 + */
908 + public function get_object_content($key) {
909 + if(!$this->DOClient) return false;
910 + try {
911 + $result = $this->DOClient->GetObject([
912 + 'Bucket' => $this->bucket_name,
913 + 'Key' => $key,
914 + ]);
915 + return (string) $result['Body'];
916 + } catch (AwsException $e) {
917 + return false;
918 + }
919 + }
920 +
921 + /**
922 + * Deletes the live object, then best-effort purges every historical version too — a
923 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
924 + * versions (and the storage they use) behind at the old key. The live delete happens
925 + * unconditionally first: DigitalOcean Spaces doesn't support object versioning at all,
926 + * so the version-listing part below simply fails there (caught, non-fatal) — the object
927 + * must still end up gone either way, which is why it can't be the only delete call.
928 + * @since 1.3.14
929 + */
930 + public function purge_all_versions($key) {
931 + if (!$this->DOClient) {
932 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
933 + }
934 +
935 + try {
936 + $this->DOClient->deleteObject([
937 + 'Bucket' => $this->bucket_name,
938 + 'Key' => $key,
939 + ]);
940 + } catch (AwsException $e) {
941 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
942 + }
943 +
944 + // Best-effort only from here — Spaces doesn't support version listing at all, so
945 + // this always no-ops there; the live object above is already gone regardless.
946 + try {
947 + $objects = [];
948 + $marker = null;
949 + do {
950 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
951 + if ($marker) {
952 + $args['KeyMarker'] = $marker['key'];
953 + $args['VersionIdMarker'] = $marker['version'];
954 + }
955 + $result = $this->DOClient->listObjectVersions($args);
956 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
957 + if (($version['Key'] ?? null) === $key) {
958 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
959 + }
960 + }
961 + $marker = !empty($result['IsTruncated'])
962 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
963 + : null;
964 + } while ($marker);
965 +
966 + foreach (array_chunk($objects, 1000) as $chunk) {
967 + $this->DOClient->deleteObjects([
968 + 'Bucket' => $this->bucket_name,
969 + 'Delete' => ['Objects' => $chunk],
970 + ]);
971 + }
972 + } catch (AwsException $e) {
973 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
974 + }
975 +
976 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
977 + }
978 +
979 + /**
777 980 * Copy to new path
778 981 * @since 1.3.4
779 982 */
983 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
984 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
985 + // processing keys sequentially, three extra round-trips per file adds up fast on a
986 + // folder with many files. copyObject() itself throws (caught below) if the source is
987 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
780 988 public function copy_to_new_path($key, $new_path) {
989 + if (!$this->DOClient) {
990 + return [
991 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
992 + 'code' => 200,
993 + 'success' => false
994 + ];
995 + }
781 996 try {
782 - // Step 1: Verify object exists at old location
783 - if (!$this->exists($key)) {
784 - return [
785 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
786 - 'code' => 200,
787 - 'success' => false
788 - ];
789 - }
790 - // Step 2: Copy object
791 - if (!$this->exists($new_path)) {
792 - $this->DOClient->copyObject([
793 - 'Bucket' => $this->bucket_name,
794 - 'CopySource' => "{$this->bucket_name}/{$key}",
795 - 'Key' => $new_path,
796 - 'MetadataDirective' => 'COPY',
797 - ]);
798 - }
799 - // Step 3: Verify object exists at new location
800 - if ($this->exists($new_path)) {
801 - return [
802 - 'success' => true,
803 - 'code' => 200,
804 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
805 - ];
806 - }
997 + $this->DOClient->copyObject([
998 + 'Bucket' => $this->bucket_name,
999 + 'CopySource' => "{$this->bucket_name}/{$key}",
1000 + 'Key' => $new_path,
1001 + 'MetadataDirective' => 'COPY',
1002 + ]);
1003 + return [
1004 + 'success' => true,
1005 + 'code' => 200,
1006 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1007 + ];
807 1008 } catch (AwsException $e) {
808 1009 return [
809 1010 'success' => false,
810 1011 'code' => 200,
@@ -810,16 +1011,42 @@
810 1011 'code' => 200,
811 1012 'message' => $e->getMessage()
812 1013 ];
813 1014 }
814 - return [
815 - 'success' => false,
816 - 'code' => 200,
817 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
818 - ];
819 1015 }
820 1016
1017 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
1018 + // access there too, so callers should fall back to download+upload on failure.
1019 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
1020 + if (!$this->DOClient) {
1021 + return [
1022 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1023 + 'code' => 200,
1024 + 'success' => false
1025 + ];
1026 + }
1027 + try {
1028 + $this->DOClient->copyObject([
1029 + 'Bucket' => $dest_bucket,
1030 + 'CopySource' => "{$this->bucket_name}/{$key}",
1031 + 'Key' => $new_key,
1032 + 'MetadataDirective' => 'COPY',
1033 + ]);
1034 + return [
1035 + 'success' => true,
1036 + 'code' => 200,
1037 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1038 + ];
1039 + } catch (AwsException $e) {
1040 + return [
1041 + 'success' => false,
1042 + 'code' => 200,
1043 + 'message' => $e->getMessage()
1044 + ];
1045 + }
1046 + }
821 1047
1048 +
822 1049 /**
823 1050 * Delete Single
824 1051 * @since 1.0.0
825 1052 * @return boolean
@@ -825,8 +1052,15 @@
825 1052 * @return boolean
826 1053 */
827 1054 public function deleteSingle($key) {
828 1055 $result = array();
1056 + if (!$this->DOClient) {
1057 + return array(
1058 + 'success' => false,
1059 + 'code' => 200,
1060 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1061 + );
1062 + }
829 1063 if (isset($key) && !empty($key)) {
830 1064 try {
831 1065 $this->DOClient->deleteObject([
832 1066 'Bucket' => $this->bucket_name,
@@ -869,8 +1103,15 @@
869 1103 * @return boolean
870 1104 */
871 1105 public function get_private_url($key) {
872 1106 $result = array();
1107 + if (!$this->DOClient) {
1108 + return array(
1109 + 'success' => false,
1110 + 'code' => 200,
1111 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1112 + );
1113 + }
873 1114 if (isset($key) && !empty($key)) {
874 1115 try {
875 1116 $cmd = $this->DOClient->getCommand('GetObject', [
876 1117 'Bucket' => $this->bucket_name,