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 -178 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 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,16 +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 - Utils::set_status('cdnRead', $result);
491 526 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
492 527 }
493 528
494 529 try {
495 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
530 + $object_key = Utils::get_permission_check_object_key();
496 531
497 532 // Check if the object was created successfully
498 533 if (!$this->exists($object_key)) {
499 534 // Create a dummy object to check write permission
@@ -500,31 +535,38 @@
500 535 $this->DOClient->putObject([
501 536 'Bucket' => $this->bucket_name,
502 537 'Key' => $object_key,
503 538 'Body' => 'This is a test object to check permission.',
539 + 'ContentType' => 'text/plain',
540 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
504 541 ]);
505 - }
506 -
542 + }
507 543
544 +
508 545 $url = $this->generate_file_url($object_key);
509 546 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
510 - $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;
511 554
512 - if (strpos($headers[0], '200') !== false) {
555 + if ($status_code === 200) {
513 556 $result['status'] = true;
514 557 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
515 - } else if (strpos($headers[0], '403') !== false) {
558 + } else if ($status_code === 403) {
516 559 $result['status'] = false;
517 - if($this->cdnConfig['service'] == $this->service) {
560 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
518 561 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
519 562 } else {
520 563 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
521 564 }
522 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
523 - } else if (strpos($headers[0], '404') !== false) {
565 + } else if ($status_code === 404) {
524 566 $result['status'] = false;
525 567 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
526 - } else if (strpos($headers[0], '500') !== false) {
568 + } else if ($status_code === 500) {
527 569 $result['status'] = false;
528 570 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
529 571 } else {
530 572 $result['status'] = false;
@@ -529,9 +571,8 @@
529 571 } else {
530 572 $result['status'] = false;
531 573 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
532 574 }
533 - Utils::set_status('cdnRead', $result);
534 575
535 576 $this->deleteSingle($object_key);
536 577 return [
537 578 'message' => $result['message'],
@@ -540,17 +581,14 @@
540 581 'lastChecked' => $result['lastChecked'],
541 582 ];
542 583 } catch (AwsException $ex) {
543 584 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
544 - Utils::set_status('cdnRead', $result);
545 585 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
546 586 } catch (S3Exception $ex) {
547 587 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
548 - Utils::set_status('cdnRead', $result);
549 588 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
550 589 } catch (Exception $ex) {
551 590 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
552 - Utils::set_status('cdnRead', $result);
553 591 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
554 592 }
555 593 }
556 594
@@ -560,17 +598,17 @@
560 598 */
561 599 public function isConfigured(){
562 600 if ($this->DOClient) {
563 601 try {
564 - $DOClient->listObjectsV2([
602 + $this->DOClient->listObjectsV2([
565 603 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
566 604 ]);
567 -
605 +
568 606 // If we reach here, the credentials are valid
569 607 return true;
570 608 } catch (AwsException $e) {
571 609 $code = $e->getAwsErrorCode();
572 -
610 +
573 611 $validErrors = [
574 612 'AccessDenied',
575 613 'NoSuchBucket',
576 614 'AllAccessDisabled',
@@ -577,12 +615,12 @@
577 615 'AuthorizationHeaderMalformed',
578 616 'PermanentRedirect',
579 617 'InvalidBucketName',
580 618 ];
581 -
619 +
582 620 if (in_array($code, $validErrors)) {
583 621 // If we reach here, the credentials are valid
584 - return false;
622 + return true;
585 623 } else {
586 624 // If we reach here, the credentials are not valid
587 625 return false;
588 626 }
@@ -597,8 +635,9 @@
597 635 *
598 636 */
599 637 public function toPrivate($key) {
600 638 if(!$key) return false;
639 + if(!$this->DOClient) return false;
601 640 try {
602 641 $this->DOClient->putObjectAcl([
603 642 'Bucket' => $this->bucket_name,
604 643 'Key' => $key,
@@ -607,9 +646,8 @@
607 646 return true;
608 647 } catch (AwsException $ex) {
609 648 return false;
610 649 }
611 - return false;
612 650 }
613 651
614 652
615 653
@@ -615,23 +653,23 @@
615 653
616 654 /**
617 655 * Make Object Public
618 656 * @since 1.0.0
619 - *
657 + *
620 658 */
621 659 public function toPublic($key) {
622 660 if(!$key) return false;
661 + if(!$this->DOClient) return false;
623 662 try {
624 663 $this->DOClient->putObjectAcl([
625 664 'Bucket' => $this->bucket_name,
626 665 'Key' => $key,
627 666 'ACL' => 'public-read'
628 - ]);
667 + ]);
629 668 return true;
630 669 } catch (AwsException $ex) {
631 670 return false;
632 671 }
633 - return false;
634 672 }
635 673
636 674
637 675
@@ -647,8 +685,9 @@
647 685 $bucket_name = !empty($bucket_name) ? $bucket_name : $this->bucket_name;
648 686 if($client->doesObjectExistV2($bucket_name, $key)) {
649 687 return true;
650 688 }
689 + return false;
651 690 } catch (AwsException $ex) {
652 691 return false;
653 692 }
654 693 catch (S3Exception $ex) {
@@ -658,14 +697,67 @@
658 697 }
659 698 }
660 699
661 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 + /**
662 755 * Upload Single
663 756 * @since 1.0.0
664 757 * @return boolean
665 758 */
666 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
667 - $result = array();
759 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
668 760 if (
669 761 isset($absolute_source_path) && !empty($absolute_source_path) &&
670 762 isset($relative_source_path) && !empty($relative_source_path)
671 763 ) {
@@ -670,93 +762,122 @@
670 762 isset($relative_source_path) && !empty($relative_source_path)
671 763 ) {
672 764 $file_name = wp_basename( $relative_source_path );
673 765 if ($file_name) {
674 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
675 -
676 - // Decide Multipart upload or normal put object
677 - if (filesize($absolute_source_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
678 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
679 - try {
680 - $handle = fopen($absolute_source_path, 'rb');
681 - $upload = $this->DOClient->putObject([
682 - 'Bucket' => $this->bucket_name,
683 - 'Key' => $upload_path,
684 - 'Body' => $handle,
685 - ]);
686 - if (is_resource($handle)) {
687 - fclose($handle);
688 - }
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 + }
689 788
690 - $result = array(
691 - 'success' => true,
692 - 'code' => 200,
693 - 'file_url' => $this->generate_file_url($upload_path),
694 - 'key' => $upload_path,
695 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
696 - );
697 - } catch (AwsException $e) {
698 - $result = array(
699 - 'success' => false,
700 - 'code' => 200,
701 - 'message' => $e->getMessage()
702 - );
703 - }
704 - } else {
705 - $multiUploader = new MultipartUploader($this->DOClient, $absolute_source_path, [
706 - 'bucket' => $this->bucket_name,
707 - 'key' => $upload_path,
708 - ]);
709 -
710 - try {
711 - do {
712 - try {
713 - $uploaded = $multiUploader->upload();
714 - } catch (MultipartUploadException $e) {
715 - $multiUploader = new MultipartUploader($this->DOClient, $absolute_source_path, [
716 - 'state' => $e->getState(),
717 - ]);
718 - }
719 - } 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 + }
720 796
721 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
722 - $result = array(
723 - 'success' => true,
724 - 'code' => 200,
725 - 'file_url' => $this->generate_file_url($upload_path),
726 - 'key' => $upload_path,
727 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
728 - );
729 - } else {
730 - $result = array(
731 - 'success' => false,
732 - 'code' => 200,
733 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
734 - );
735 - }
736 - } catch (MultipartUploadException $e) {
737 - $result = array(
738 - 'success' => false,
739 - 'code' => 200,
740 - 'message' => $e->getMessage()
741 - );
742 - }
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 + ];
743 864 }
744 - } else {
745 - $result = array(
865 + $options = ['state' => $e->getState()];
866 + } catch (AwsException $e) {
867 + return [
746 868 'success' => false,
747 869 'code' => 200,
748 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
749 - );
870 + 'message' => $e->getMessage()
871 + ];
872 + } catch (Exception $e) {
873 + return [
874 + 'success' => false,
875 + 'code' => 200,
876 + 'message' => $e->getMessage()
877 + ];
750 878 }
751 - } else {
752 - $result = array(
753 - 'success' => false,
754 - 'code' => 200,
755 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
756 - );
757 879 }
758 - return $result;
759 880 }
760 881
761 882 /**
762 883 * Save object to server
@@ -762,8 +883,9 @@
762 883 * Save object to server
763 884 * @since 1.0.0
764 885 */
765 886 public function object_to_server($key, $save_path) {
887 + if(!$this->DOClient) return false;
766 888 try {
767 889 $getObject = $this->DOClient->GetObject([
768 890 'Bucket' => $this->bucket_name,
769 891 'Key' => $key,
@@ -778,38 +900,112 @@
778 900 return false;
779 901 }
780 902
781 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 + /**
782 980 * Copy to new path
783 981 * @since 1.3.4
784 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.
785 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 + }
786 996 try {
787 - // Step 1: Verify object exists at old location
788 - if (!$this->exists($key)) {
789 - return [
790 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
791 - 'code' => 200,
792 - 'success' => false
793 - ];
794 - }
795 - // Step 2: Copy object
796 - if (!$this->exists($new_path)) {
797 - $this->DOClient->copyObject([
798 - 'Bucket' => $this->bucket_name,
799 - 'CopySource' => "{$this->bucket_name}/{$key}",
800 - 'Key' => $new_path,
801 - 'MetadataDirective' => 'COPY',
802 - ]);
803 - }
804 - // Step 3: Verify object exists at new location
805 - if ($this->exists($new_path)) {
806 - return [
807 - 'success' => true,
808 - 'code' => 200,
809 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
810 - ];
811 - }
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 + ];
812 1008 } catch (AwsException $e) {
813 1009 return [
814 1010 'success' => false,
815 1011 'code' => 200,
@@ -815,16 +1011,42 @@
815 1011 'code' => 200,
816 1012 'message' => $e->getMessage()
817 1013 ];
818 1014 }
819 - return [
820 - 'success' => false,
821 - 'code' => 200,
822 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
823 - ];
824 1015 }
825 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 + }
826 1047
1048 +
827 1049 /**
828 1050 * Delete Single
829 1051 * @since 1.0.0
830 1052 * @return boolean
@@ -830,8 +1052,15 @@
830 1052 * @return boolean
831 1053 */
832 1054 public function deleteSingle($key) {
833 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 + }
834 1063 if (isset($key) && !empty($key)) {
835 1064 try {
836 1065 $this->DOClient->deleteObject([
837 1066 'Bucket' => $this->bucket_name,
@@ -874,8 +1103,15 @@
874 1103 * @return boolean
875 1104 */
876 1105 public function get_private_url($key) {
877 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 + }
878 1114 if (isset($key) && !empty($key)) {
879 1115 try {
880 1116 $cmd = $this->DOClient->getCommand('GetObject', [
881 1117 'Bucket' => $this->bucket_name,