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 +520 -182 1.2.131.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'])
@@ -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 {
@@ -283,10 +311,17 @@
283 311
284 312
285 313 /**
286 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
287 322 */
288 - private function putBucketPolicy($bucket, $DOClient = false) {
323 + private function putBucketPolicy($bucket, $DOClient = false, $private_prefix = '') {
289 324 if($DOClient == false) {
290 325 $DOClient = $this->DOClient;
291 326 }
292 327
@@ -291,38 +326,45 @@
291 326 }
292 327
293 328 if(empty($bucket)) return false;
294 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 +
295 364 $policy = json_encode([
296 - "Version" => "2012-10-17",
297 - "Statement" => [
298 - [
299 - "Effect" => "Allow",
300 - "Principal" => "*",
301 - "Action" => [
302 - "s3:DeleteObjectTagging",
303 - "s3:ListBucketMultipartUploads",
304 - "s3:DeleteObjectVersion",
305 - "s3:ListBucket",
306 - "s3:DeleteObjectVersionTagging",
307 - "s3:GetBucketAcl",
308 - "s3:ListMultipartUploadParts",
309 - "s3:PutObject",
310 - "s3:GetObjectAcl",
311 - "s3:GetObject",
312 - "s3:AbortMultipartUpload",
313 - "s3:DeleteObject",
314 - "s3:GetBucketLocation",
315 - "s3:PutObjectAcl",
316 - "s3:putBucketOwnershipControls",
317 - "s3:putBucketPolicy"
318 - ],
319 - "Resource" => [
320 - "arn:aws:s3:::$bucket/*",
321 - "arn:aws:s3:::$bucket"
322 - ]
323 - ]
324 - ]
365 + "Version" => "2012-10-17",
366 + "Statement" => [$statement]
325 367 ]);
326 368
327 369 try {
328 370 // Add bucket policy
@@ -337,16 +379,38 @@
337 379 return false; // Handle general exceptions
338 380 }
339 381 }
340 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 + }
341 392
393 + $ok = $this->putBucketPolicy($this->bucket_name, $this->DOClient, $private_prefix);
342 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 +
343 402 /**
344 403 * Check Bucket Write Permission
345 404 * @since 1.0.0
346 405 */
347 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name){
348 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
406 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
407 + $region = isset($config['region']) ? $config['region'] : '';
408 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
409 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
410 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
411 +
412 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
349 413 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
350 414 }
351 415
352 416 try {
@@ -364,9 +428,9 @@
364 428 'secret' => $secret_key,
365 429 ],
366 430 ]);
367 431
368 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
432 + $object_key = Utils::get_permission_check_object_key();
369 433
370 434
371 435 // Create a dummy object to check write permission
372 436 $DOClient->putObject([
@@ -374,9 +438,9 @@
374 438 'Key' => $object_key,
375 439 'Body' => 'This is a test object to check write permission.',
376 440 ]);
377 441 // Check if the object was created successfully
378 - if ($DOClient->doesObjectExist($bucket_name, $object_key)) {
442 + if ($this->exists($object_key, $bucket_name, $DOClient)) {
379 443 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
380 444 } else {
381 445 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
382 446 }
@@ -395,10 +459,15 @@
395 459 /**
396 460 * Check Bucket Delete Permission
397 461 * @since 1.0.0
398 462 */
399 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name){
400 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
463 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
464 + $region = isset($config['region']) ? $config['region'] : '';
465 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
466 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
467 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
468 +
469 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
401 470 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
402 471 }
403 472
404 473 try {
@@ -416,9 +485,9 @@
416 485 'secret' => $secret_key,
417 486 ],
418 487 ]);
419 488
420 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
489 + $object_key = Utils::get_permission_check_object_key();
421 490
422 491 // Create a dummy object to check dlete permission
423 492 $DOClient->deleteObject([
424 493 'Bucket' => $bucket_name,
@@ -425,9 +494,9 @@
425 494 'Key' => $object_key,
426 495 ]);
427 496
428 497 // Check if the object was created successfully
429 - if (!$DOClient->doesObjectExist($bucket_name, $object_key)) {
498 + if (!$this->exists($object_key, $bucket_name, $DOClient)) {
430 499 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
431 500 } else {
432 501 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
433 502 }
@@ -446,49 +515,58 @@
446 515 * Check Bucket Read Permission
447 516 * @since 1.2.4
448 517 */
449 518 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];
519 + $result = [
520 + 'status' => false,
521 + 'message' => '',
522 + 'lastChecked' => time(),
523 + ];
524 + if (Service::has_missing_fields([$this->DOClient, $this->bucket_name])) {
525 + $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
526 + return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
452 527 }
453 528
454 529 try {
455 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
530 + $object_key = Utils::get_permission_check_object_key();
456 531
457 532 // Check if the object was created successfully
458 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $object_key)) {
533 + if (!$this->exists($object_key)) {
459 534 // Create a dummy object to check write permission
460 535 $this->DOClient->putObject([
461 536 'Bucket' => $this->bucket_name,
462 537 'Key' => $object_key,
463 538 'Body' => 'This is a test object to check permission.',
539 + 'ContentType' => 'text/plain',
540 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
464 541 ]);
465 - }
466 -
542 + }
467 543
544 +
468 545 $url = $this->generate_file_url($object_key);
469 546 $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) {
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;
554 +
555 + if ($status_code === 200) {
477 556 $result['status'] = true;
478 557 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
479 - } else if (strpos($headers[0], '403') !== false) {
558 + } else if ($status_code === 403) {
480 559 $result['status'] = false;
481 - if($this->cdnConfig['service'] == $this->service) {
560 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
482 561 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
483 562 } else {
484 563 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
485 564 }
486 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
487 - } else if (strpos($headers[0], '404') !== false) {
565 + } else if ($status_code === 404) {
488 566 $result['status'] = false;
489 567 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
490 - } else if (strpos($headers[0], '500') !== false) {
568 + } else if ($status_code === 500) {
491 569 $result['status'] = false;
492 570 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
493 571 } else {
494 572 $result['status'] = false;
@@ -493,9 +571,8 @@
493 571 } else {
494 572 $result['status'] = false;
495 573 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
496 574 }
497 - Utils::set_status('cdnRead', $result);
498 575
499 576 $this->deleteSingle($object_key);
500 577 return [
501 578 'message' => $result['message'],
@@ -503,13 +580,16 @@
503 580 'success' => $result['status'],
504 581 'lastChecked' => $result['lastChecked'],
505 582 ];
506 583 } catch (AwsException $ex) {
507 - return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
584 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
585 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
508 586 } catch (S3Exception $ex) {
509 - return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
587 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
588 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
510 589 } catch (Exception $ex) {
511 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
590 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
591 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
512 592 }
513 593 }
514 594
515 595 /**
@@ -518,17 +598,17 @@
518 598 */
519 599 public function isConfigured(){
520 600 if ($this->DOClient) {
521 601 try {
522 - $DOClient->listObjectsV2([
602 + $this->DOClient->listObjectsV2([
523 603 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
524 604 ]);
525 -
605 +
526 606 // If we reach here, the credentials are valid
527 607 return true;
528 608 } catch (AwsException $e) {
529 609 $code = $e->getAwsErrorCode();
530 -
610 +
531 611 $validErrors = [
532 612 'AccessDenied',
533 613 'NoSuchBucket',
534 614 'AllAccessDisabled',
@@ -535,12 +615,12 @@
535 615 'AuthorizationHeaderMalformed',
536 616 'PermanentRedirect',
537 617 'InvalidBucketName',
538 618 ];
539 -
619 +
540 620 if (in_array($code, $validErrors)) {
541 621 // If we reach here, the credentials are valid
542 - return false;
622 + return true;
543 623 } else {
544 624 // If we reach here, the credentials are not valid
545 625 return false;
546 626 }
@@ -555,8 +635,9 @@
555 635 *
556 636 */
557 637 public function toPrivate($key) {
558 638 if(!$key) return false;
639 + if(!$this->DOClient) return false;
559 640 try {
560 641 $this->DOClient->putObjectAcl([
561 642 'Bucket' => $this->bucket_name,
562 643 'Key' => $key,
@@ -565,9 +646,8 @@
565 646 return true;
566 647 } catch (AwsException $ex) {
567 648 return false;
568 649 }
569 - return false;
570 650 }
571 651
572 652
573 653
@@ -573,23 +653,23 @@
573 653
574 654 /**
575 655 * Make Object Public
576 656 * @since 1.0.0
577 - *
657 + *
578 658 */
579 659 public function toPublic($key) {
580 660 if(!$key) return false;
661 + if(!$this->DOClient) return false;
581 662 try {
582 663 $this->DOClient->putObjectAcl([
583 664 'Bucket' => $this->bucket_name,
584 665 'Key' => $key,
585 666 'ACL' => 'public-read'
586 - ]);
667 + ]);
587 668 return true;
588 669 } catch (AwsException $ex) {
589 670 return false;
590 671 }
591 - return false;
592 672 }
593 673
594 674
595 675
@@ -596,15 +676,18 @@
596 676 /**
597 677 * Check the object exist
598 678 * @since 1.1.8
599 679 */
600 - public function exists($key) {
680 + public function exists($key, $bucket_name = '', $client = null) {
601 681 if(!$key) return false;
602 682
603 683 try {
604 - if($this->DOClient->doesObjectExist($this->bucket_name, $key)) {
684 + $client = $client ?? $this->DOClient;
685 + $bucket_name = !empty($bucket_name) ? $bucket_name : $this->bucket_name;
686 + if($client->doesObjectExistV2($bucket_name, $key)) {
605 687 return true;
606 688 }
689 + return false;
607 690 } catch (AwsException $ex) {
608 691 return false;
609 692 }
610 693 catch (S3Exception $ex) {
@@ -614,101 +697,187 @@
614 697 }
615 698 }
616 699
617 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 + /**
618 755 * Upload Single
619 756 * @since 1.0.0
620 757 * @return boolean
621 758 */
622 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
623 - $result = array();
759 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
624 760 if (
625 - isset($media_absolute_path) && !empty($media_absolute_path) &&
626 - isset($media_path) && !empty($media_path)
761 + isset($absolute_source_path) && !empty($absolute_source_path) &&
762 + isset($relative_source_path) && !empty($relative_source_path)
627 763 ) {
628 - $file_name = wp_basename( $media_path );
764 + $file_name = wp_basename( $relative_source_path );
629 765 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 - ]);
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 + }
641 788
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));
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 + }
672 796
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 - }
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 + ];
695 864 }
696 - } else {
697 - $result = array(
865 + $options = ['state' => $e->getState()];
866 + } catch (AwsException $e) {
867 + return [
698 868 'success' => false,
699 869 'code' => 200,
700 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
701 - );
870 + 'message' => $e->getMessage()
871 + ];
872 + } catch (Exception $e) {
873 + return [
874 + 'success' => false,
875 + 'code' => 200,
876 + 'message' => $e->getMessage()
877 + ];
702 878 }
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 879 }
710 - return $result;
711 880 }
712 881
713 882 /**
714 883 * Save object to server
@@ -714,8 +883,9 @@
714 883 * Save object to server
715 884 * @since 1.0.0
716 885 */
717 886 public function object_to_server($key, $save_path) {
887 + if(!$this->DOClient) return false;
718 888 try {
719 889 $getObject = $this->DOClient->GetObject([
720 890 'Bucket' => $this->bucket_name,
721 891 'Key' => $key,
@@ -729,10 +899,155 @@
729 899 }
730 900 return false;
731 901 }
732 902
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 + }
733 920
734 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 + /**
980 + * Copy to new path
981 + * @since 1.3.4
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.
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 + }
996 + try {
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 + ];
1008 + } catch (AwsException $e) {
1009 + return [
1010 + 'success' => false,
1011 + 'code' => 200,
1012 + 'message' => $e->getMessage()
1013 + ];
1014 + }
1015 + }
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 + }
1047 +
1048 +
1049 + /**
735 1050 * Delete Single
736 1051 * @since 1.0.0
737 1052 * @return boolean
738 1053 */
@@ -737,8 +1052,15 @@
737 1052 * @return boolean
738 1053 */
739 1054 public function deleteSingle($key) {
740 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 + }
741 1063 if (isset($key) && !empty($key)) {
742 1064 try {
743 1065 $this->DOClient->deleteObject([
744 1066 'Bucket' => $this->bucket_name,
@@ -744,9 +1066,9 @@
744 1066 'Bucket' => $this->bucket_name,
745 1067 'Key' => $key
746 1068 ]);
747 1069
748 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $key)) {
1070 + if (!$this->exists($key)) {
749 1071 $result = array(
750 1072 'success' => true,
751 1073 'code' => 200,
752 1074 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -775,14 +1097,21 @@
775 1097 return $result;
776 1098 }
777 1099
778 1100 /**
779 - * get presigned URL
1101 + * get private URL
780 1102 * @since 1.0.0
781 1103 * @return boolean
782 1104 */
783 - public function get_presigned_url($key) {
1105 + public function get_private_url($key) {
784 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 + }
785 1114 if (isset($key) && !empty($key)) {
786 1115 try {
787 1116 $cmd = $this->DOClient->getCommand('GetObject', [
788 1117 'Bucket' => $this->bucket_name,
@@ -788,24 +1117,24 @@
788 1117 'Bucket' => $this->bucket_name,
789 1118 'Key' => $key
790 1119 ]);
791 1120
792 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1121 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
793 1122
794 1123 $request = $this->DOClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
795 1124
796 - if ($presignedUrl = (string)$request->getUri()) {
1125 + if ($privateUrl = (string)$request->getUri()) {
797 1126 $result = array(
798 1127 'success' => true,
799 1128 'code' => 200,
800 - 'file_url' => $presignedUrl,
801 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1129 + 'file_url' => $privateUrl,
1130 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
802 1131 );
803 1132 } else {
804 1133 $result = array(
805 1134 'success' => false,
806 1135 'code' => 200,
807 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1136 + 'message' => esc_html__('Error getting Private URL', 'media-cloud-sync')
808 1137 );
809 1138 }
810 1139 } catch (AwsException $e) {
811 1140 $result = array(
@@ -826,9 +1155,9 @@
826 1155
827 1156 /**
828 1157 * Generate file URL
829 1158 */
830 - private function generate_file_url($key){
1159 + public function generate_file_url($key){
831 1160 $domain = $this->get_domain();
832 1161
833 1162 return apply_filters('wpmcs_generate_do_file_url',
834 1163 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -835,8 +1164,17 @@
835 1164 $domain,
836 1165 $this->bucket_name,
837 1166 $key
838 1167 );
1168 + }
1169 +
1170 + /**
1171 + * Is Provider URL
1172 + * @since 1.3.6
1173 + */
1174 + public function is_provider_url($url) {
1175 + $domain = $this->get_domain();
1176 + return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
839 1177 }
840 1178
841 1179 /**
842 1180 * Get domain URL