PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/base/services/docean.php +527 -182 1.2.5 → 1.4.2 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'),
@@ -134,8 +138,9 @@
134 138 'NoSuchBucket',
135 139 'AllAccessDisabled',
136 140 'AuthorizationHeaderMalformed',
137 141 'PermanentRedirect',
142 + 'InvalidBucketName',
138 143 ];
139 144
140 145 if (in_array($code, $validErrors)) {
141 146 // If we reach here, the credentials are valid
@@ -149,11 +154,30 @@
149 154
150 155 if($result['success'] == false) {
151 156 return $result;
152 157 }
153 - $result['buckets_data']['buckets'] = [];
154 - $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
155 - $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 + }
156 180 return $result;
157 181 } catch (S3Exception $ex) {
158 182 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
159 183 } catch (Exception $ex) {
@@ -167,15 +191,15 @@
167 191 * Verify Bucket
168 192 * @since 1.0.0
169 193 * @return boolean
170 194 */
171 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name){
172 - if (
173 - isset($region) && !empty($region) &&
174 - isset($access_key) && !empty($access_key) &&
175 - isset($secret_key) && !empty($secret_key) &&
176 - isset($bucket_name) && !empty($bucket_name)
177 - ) {
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])) {
178 202 try {
179 203 $endpoint = $this->get_domain($region);
180 204
181 205 $DOClient = new S3Client([
@@ -225,10 +249,15 @@
225 249 * Create Bucket
226 250 * @since 1.0.0
227 251 * @return boolean
228 252 */
229 - public function createBucket($access_key, $secret_key, $region, $bucket_name){
230 - 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])) {
231 260 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
232 261 }
233 262
234 263 try {
@@ -282,10 +311,17 @@
282 311
283 312
284 313 /**
285 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
286 322 */
287 - private function putBucketPolicy($bucket, $DOClient = false) {
323 + private function putBucketPolicy($bucket, $DOClient = false, $private_prefix = '') {
288 324 if($DOClient == false) {
289 325 $DOClient = $this->DOClient;
290 326 }
291 327
@@ -290,38 +326,45 @@
290 326 }
291 327
292 328 if(empty($bucket)) return false;
293 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 +
294 364 $policy = json_encode([
295 - "Version" => "2012-10-17",
296 - "Statement" => [
297 - [
298 - "Effect" => "Allow",
299 - "Principal" => "*",
300 - "Action" => [
301 - "s3:DeleteObjectTagging",
302 - "s3:ListBucketMultipartUploads",
303 - "s3:DeleteObjectVersion",
304 - "s3:ListBucket",
305 - "s3:DeleteObjectVersionTagging",
306 - "s3:GetBucketAcl",
307 - "s3:ListMultipartUploadParts",
308 - "s3:PutObject",
309 - "s3:GetObjectAcl",
310 - "s3:GetObject",
311 - "s3:AbortMultipartUpload",
312 - "s3:DeleteObject",
313 - "s3:GetBucketLocation",
314 - "s3:PutObjectAcl",
315 - "s3:putBucketOwnershipControls",
316 - "s3:putBucketPolicy"
317 - ],
318 - "Resource" => [
319 - "arn:aws:s3:::$bucket/*",
320 - "arn:aws:s3:::$bucket"
321 - ]
322 - ]
323 - ]
365 + "Version" => "2012-10-17",
366 + "Statement" => [$statement]
324 367 ]);
325 368
326 369 try {
327 370 // Add bucket policy
@@ -336,16 +379,38 @@
336 379 return false; // Handle general exceptions
337 380 }
338 381 }
339 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 + }
340 392
393 + $ok = $this->putBucketPolicy($this->bucket_name, $this->DOClient, $private_prefix);
341 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 +
342 402 /**
343 403 * Check Bucket Write Permission
344 404 * @since 1.0.0
345 405 */
346 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name){
347 - 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])) {
348 413 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
349 414 }
350 415
351 416 try {
@@ -363,9 +428,9 @@
363 428 'secret' => $secret_key,
364 429 ],
365 430 ]);
366 431
367 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
432 + $object_key = Utils::get_permission_check_object_key();
368 433
369 434
370 435 // Create a dummy object to check write permission
371 436 $DOClient->putObject([
@@ -373,9 +438,9 @@
373 438 'Key' => $object_key,
374 439 'Body' => 'This is a test object to check write permission.',
375 440 ]);
376 441 // Check if the object was created successfully
377 - if ($DOClient->doesObjectExist($bucket_name, $object_key)) {
442 + if ($this->exists($object_key, $bucket_name, $DOClient)) {
378 443 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
379 444 } else {
380 445 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
381 446 }
@@ -394,10 +459,15 @@
394 459 /**
395 460 * Check Bucket Delete Permission
396 461 * @since 1.0.0
397 462 */
398 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name){
399 - 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])) {
400 470 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
401 471 }
402 472
403 473 try {
@@ -415,9 +485,9 @@
415 485 'secret' => $secret_key,
416 486 ],
417 487 ]);
418 488
419 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
489 + $object_key = Utils::get_permission_check_object_key();
420 490
421 491 // Create a dummy object to check dlete permission
422 492 $DOClient->deleteObject([
423 493 'Bucket' => $bucket_name,
@@ -424,9 +494,9 @@
424 494 'Key' => $object_key,
425 495 ]);
426 496
427 497 // Check if the object was created successfully
428 - if (!$DOClient->doesObjectExist($bucket_name, $object_key)) {
498 + if (!$this->exists($object_key, $bucket_name, $DOClient)) {
429 499 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
430 500 } else {
431 501 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
432 502 }
@@ -445,49 +515,58 @@
445 515 * Check Bucket Read Permission
446 516 * @since 1.2.4
447 517 */
448 518 public function verifyObjectReadPermission() {
449 - if (empty($this->DOClient) || empty($this->bucket_name)) {
450 - 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()];
451 527 }
452 528
453 529 try {
454 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
530 + $object_key = Utils::get_permission_check_object_key();
455 531
456 532 // Check if the object was created successfully
457 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $object_key)) {
533 + if (!$this->exists($object_key)) {
458 534 // Create a dummy object to check write permission
459 535 $this->DOClient->putObject([
460 536 'Bucket' => $this->bucket_name,
461 537 'Key' => $object_key,
462 538 'Body' => 'This is a test object to check permission.',
539 + 'ContentType' => 'text/plain',
540 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
463 541 ]);
464 - }
465 -
542 + }
466 543
544 +
467 545 $url = $this->generate_file_url($object_key);
468 546 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
469 - $headers = @get_headers($cdn_url);
470 - $result = [
471 - 'status' => false,
472 - 'message' => '',
473 - 'lastChecked' => time(),
474 - ];
475 - 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) {
476 556 $result['status'] = true;
477 557 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
478 - } else if (strpos($headers[0], '403') !== false) {
558 + } else if ($status_code === 403) {
479 559 $result['status'] = false;
480 - if($this->cdnConfig['service'] == $this->service) {
560 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
481 561 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
482 562 } else {
483 563 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
484 564 }
485 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
486 - } else if (strpos($headers[0], '404') !== false) {
565 + } else if ($status_code === 404) {
487 566 $result['status'] = false;
488 567 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
489 - } else if (strpos($headers[0], '500') !== false) {
568 + } else if ($status_code === 500) {
490 569 $result['status'] = false;
491 570 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
492 571 } else {
493 572 $result['status'] = false;
@@ -492,9 +571,8 @@
492 571 } else {
493 572 $result['status'] = false;
494 573 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
495 574 }
496 - Utils::set_status('cdnRead', $result);
497 575
498 576 $this->deleteSingle($object_key);
499 577 return [
500 578 'message' => $result['message'],
@@ -502,13 +580,16 @@
502 580 'success' => $result['status'],
503 581 'lastChecked' => $result['lastChecked'],
504 582 ];
505 583 } catch (AwsException $ex) {
506 - 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()];
507 586 } catch (S3Exception $ex) {
508 - 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()];
509 589 } catch (Exception $ex) {
510 - 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()];
511 592 }
512 593 }
513 594
514 595 /**
@@ -517,17 +598,17 @@
517 598 */
518 599 public function isConfigured(){
519 600 if ($this->DOClient) {
520 601 try {
521 - $DOClient->listObjectsV2([
602 + $this->DOClient->listObjectsV2([
522 603 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
523 604 ]);
524 -
605 +
525 606 // If we reach here, the credentials are valid
526 607 return true;
527 608 } catch (AwsException $e) {
528 609 $code = $e->getAwsErrorCode();
529 -
610 +
530 611 $validErrors = [
531 612 'AccessDenied',
532 613 'NoSuchBucket',
533 614 'AllAccessDisabled',
@@ -532,13 +613,14 @@
532 613 'NoSuchBucket',
533 614 'AllAccessDisabled',
534 615 'AuthorizationHeaderMalformed',
535 616 'PermanentRedirect',
617 + 'InvalidBucketName',
536 618 ];
537 -
619 +
538 620 if (in_array($code, $validErrors)) {
539 621 // If we reach here, the credentials are valid
540 - return false;
622 + return true;
541 623 } else {
542 624 // If we reach here, the credentials are not valid
543 625 return false;
544 626 }
@@ -553,8 +635,9 @@
553 635 *
554 636 */
555 637 public function toPrivate($key) {
556 638 if(!$key) return false;
639 + if(!$this->DOClient) return false;
557 640 try {
558 641 $this->DOClient->putObjectAcl([
559 642 'Bucket' => $this->bucket_name,
560 643 'Key' => $key,
@@ -563,9 +646,8 @@
563 646 return true;
564 647 } catch (AwsException $ex) {
565 648 return false;
566 649 }
567 - return false;
568 650 }
569 651
570 652
571 653
@@ -571,23 +653,23 @@
571 653
572 654 /**
573 655 * Make Object Public
574 656 * @since 1.0.0
575 - *
657 + *
576 658 */
577 659 public function toPublic($key) {
578 660 if(!$key) return false;
661 + if(!$this->DOClient) return false;
579 662 try {
580 663 $this->DOClient->putObjectAcl([
581 664 'Bucket' => $this->bucket_name,
582 665 'Key' => $key,
583 666 'ACL' => 'public-read'
584 - ]);
667 + ]);
585 668 return true;
586 669 } catch (AwsException $ex) {
587 670 return false;
588 671 }
589 - return false;
590 672 }
591 673
592 674
593 675
@@ -594,15 +676,18 @@
594 676 /**
595 677 * Check the object exist
596 678 * @since 1.1.8
597 679 */
598 - public function exists($key) {
680 + public function exists($key, $bucket_name = '', $client = null) {
599 681 if(!$key) return false;
600 682
601 683 try {
602 - 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)) {
603 687 return true;
604 688 }
689 + return false;
605 690 } catch (AwsException $ex) {
606 691 return false;
607 692 }
608 693 catch (S3Exception $ex) {
@@ -612,101 +697,187 @@
612 697 }
613 698 }
614 699
615 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 + /**
616 755 * Upload Single
617 756 * @since 1.0.0
618 757 * @return boolean
619 758 */
620 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
621 - $result = array();
759 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
622 760 if (
623 - isset($media_absolute_path) && !empty($media_absolute_path) &&
624 - isset($media_path) && !empty($media_path)
761 + isset($absolute_source_path) && !empty($absolute_source_path) &&
762 + isset($relative_source_path) && !empty($relative_source_path)
625 763 ) {
626 - $file_name = wp_basename( $media_path );
764 + $file_name = wp_basename( $relative_source_path );
627 765 if ($file_name) {
628 - $upload_path = Utils::generate_object_key($media_path, $prefix);
629 -
630 - // Decide Multipart upload or normal put object
631 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
632 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
633 - try {
634 - $upload = $this->DOClient->putObject([
635 - 'Bucket' => $this->bucket_name,
636 - 'Key' => $upload_path,
637 - 'Body' => fopen($media_absolute_path, 'r'),
638 - ]);
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 + }
639 788
640 - $result = array(
641 - 'success' => true,
642 - 'code' => 200,
643 - 'file_url' => $this->generate_file_url($upload_path),
644 - 'key' => $upload_path,
645 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
646 - );
647 - } catch (AwsException $e) {
648 - $result = array(
649 - 'success' => false,
650 - 'code' => 200,
651 - 'message' => $e->getMessage()
652 - );
653 - }
654 - } else {
655 - $multiUploader = new MultipartUploader($this->DOClient, $media_absolute_path, [
656 - 'bucket' => $this->bucket_name,
657 - 'key' => $upload_path,
658 - ]);
659 -
660 - try {
661 - do {
662 - try {
663 - $uploaded = $multiUploader->upload();
664 - } catch (MultipartUploadException $e) {
665 - $multiUploader = new MultipartUploader($this->DOClient, $media_absolute_path, [
666 - 'state' => $e->getState(),
667 - ]);
668 - }
669 - } 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 + }
670 796
671 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
672 - $result = array(
673 - 'success' => true,
674 - 'code' => 200,
675 - 'file_url' => $this->generate_file_url($upload_path),
676 - 'key' => $upload_path,
677 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
678 - );
679 - } else {
680 - $result = array(
681 - 'success' => false,
682 - 'code' => 200,
683 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
684 - );
685 - }
686 - } catch (MultipartUploadException $e) {
687 - $result = array(
688 - 'success' => false,
689 - 'code' => 200,
690 - 'message' => $e->getMessage()
691 - );
692 - }
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 + ];
693 864 }
694 - } else {
695 - $result = array(
865 + $options = ['state' => $e->getState()];
866 + } catch (AwsException $e) {
867 + return [
696 868 'success' => false,
697 869 'code' => 200,
698 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
699 - );
870 + 'message' => $e->getMessage()
871 + ];
872 + } catch (Exception $e) {
873 + return [
874 + 'success' => false,
875 + 'code' => 200,
876 + 'message' => $e->getMessage()
877 + ];
700 878 }
701 - } else {
702 - $result = array(
703 - 'success' => false,
704 - 'code' => 200,
705 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
706 - );
707 879 }
708 - return $result;
709 880 }
710 881
711 882 /**
712 883 * Save object to server
@@ -712,8 +883,9 @@
712 883 * Save object to server
713 884 * @since 1.0.0
714 885 */
715 886 public function object_to_server($key, $save_path) {
887 + if(!$this->DOClient) return false;
716 888 try {
717 889 $getObject = $this->DOClient->GetObject([
718 890 'Bucket' => $this->bucket_name,
719 891 'Key' => $key,
@@ -727,10 +899,155 @@
727 899 }
728 900 return false;
729 901 }
730 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 + }
731 920
732 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 + /**
733 1050 * Delete Single
734 1051 * @since 1.0.0
735 1052 * @return boolean
736 1053 */
@@ -735,8 +1052,15 @@
735 1052 * @return boolean
736 1053 */
737 1054 public function deleteSingle($key) {
738 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 + }
739 1063 if (isset($key) && !empty($key)) {
740 1064 try {
741 1065 $this->DOClient->deleteObject([
742 1066 'Bucket' => $this->bucket_name,
@@ -742,9 +1066,9 @@
742 1066 'Bucket' => $this->bucket_name,
743 1067 'Key' => $key
744 1068 ]);
745 1069
746 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $key)) {
1070 + if (!$this->exists($key)) {
747 1071 $result = array(
748 1072 'success' => true,
749 1073 'code' => 200,
750 1074 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -773,14 +1097,21 @@
773 1097 return $result;
774 1098 }
775 1099
776 1100 /**
777 - * get presigned URL
1101 + * get private URL
778 1102 * @since 1.0.0
779 1103 * @return boolean
780 1104 */
781 - public function get_presigned_url($key) {
1105 + public function get_private_url($key) {
782 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 + }
783 1114 if (isset($key) && !empty($key)) {
784 1115 try {
785 1116 $cmd = $this->DOClient->getCommand('GetObject', [
786 1117 'Bucket' => $this->bucket_name,
@@ -786,24 +1117,24 @@
786 1117 'Bucket' => $this->bucket_name,
787 1118 'Key' => $key
788 1119 ]);
789 1120
790 - $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;
791 1122
792 1123 $request = $this->DOClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
793 1124
794 - if ($presignedUrl = (string)$request->getUri()) {
1125 + if ($privateUrl = (string)$request->getUri()) {
795 1126 $result = array(
796 1127 'success' => true,
797 1128 'code' => 200,
798 - 'file_url' => $presignedUrl,
799 - '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')
800 1131 );
801 1132 } else {
802 1133 $result = array(
803 1134 'success' => false,
804 1135 'code' => 200,
805 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1136 + 'message' => esc_html__('Error getting Private URL', 'media-cloud-sync')
806 1137 );
807 1138 }
808 1139 } catch (AwsException $e) {
809 1140 $result = array(
@@ -824,9 +1155,9 @@
824 1155
825 1156 /**
826 1157 * Generate file URL
827 1158 */
828 - private function generate_file_url($key){
1159 + public function generate_file_url($key){
829 1160 $domain = $this->get_domain();
830 1161
831 1162 return apply_filters('wpmcs_generate_do_file_url',
832 1163 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -836,8 +1167,17 @@
836 1167 );
837 1168 }
838 1169
839 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);
1177 + }
1178 +
1179 + /**
840 1180 * Get domain URL
841 1181 */
842 1182 public function get_domain($region = '') {
843 1183 if(empty($region)) {
@@ -843,7 +1183,12 @@
843 1183 if(empty($region)) {
844 1184 $region = isset($this->config['region']) ? $this->config['region'] : '';
845 1185 }
846 1186 return "https://{$region}.digitaloceanspaces.com";
1187 + }
1188 +
1189 + /** Exposes the already-constructed SDK client for StreamWrapper's registration — avoids reconstructing one from credentials. */
1190 + public function get_client() {
1191 + return $this->DOClient;
847 1192 }
848 1193
849 1194 }