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 +615 -172 1.2.21.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;
@@ -20,8 +22,9 @@
20 22 protected $bucketConfig;
21 23 protected $settings;
22 24 protected $credentials;
23 25 protected $bucket_name;
26 + protected $cdnConfig;
24 27
25 28 public $service = 'docean';
26 29 public $DOClient = false;
27 30
@@ -28,23 +31,26 @@
28 31 /**
29 32 * Admin constructor.
30 33 * @since 1.0.0
31 34 */
32 - public function __construct() {
35 + public function __construct($credentials = null) {
33 36 $this->assets_url = WPMCS_ASSETS_URL;
34 37 $this->version = WPMCS_VERSION;
35 38 $this->token = WPMCS_TOKEN;
36 39
37 40 // Initialize setup
38 - $this->init();
41 + $this->init($credentials);
39 42 }
40 43
41 44 /**
42 45 * Initialise Client
46 + *
47 + * @param array|null $credentials Optional explicit credentials; falls back to
48 + * Utils::get_credentials() when omitted.
43 49 */
44 - public function init() {
50 + public function init($credentials = null) {
45 51 $this->settings = Utils::get_settings();
46 - $this->credentials = Utils::get_credentials();
52 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
47 53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
48 54 ? $this->credentials['config']
49 55 : [];
50 56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -52,8 +58,11 @@
52 58 : [];
53 59 $this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
54 60 ? $this->bucketConfig['bucket_name']
55 61 : '';
62 + $this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
63 + ? $this->credentials['cdn']
64 + : [];
56 65
57 66 if (
58 67 isset($this->config['region']) && !empty($this->config['region']) &&
59 68 isset($this->config['access_key']) && !empty($this->config['access_key']) &&
@@ -82,14 +91,13 @@
82 91 * Verify Credentials
83 92 * @since 1.0.0
84 93 * @return boolean
85 94 */
86 - public function verifyCredentials($access_key, $secret_key, $region){
87 - if (
88 - isset($region) && !empty($region) &&
89 - isset($access_key) && !empty($access_key) &&
90 - isset($secret_key) && !empty($secret_key)
91 - ) {
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])) {
92 100 try {
93 101 $endpoint = $this->get_domain($region);
94 102
95 103 $DOClient = new S3Client([
@@ -103,9 +111,9 @@
103 111 'key' => $access_key,
104 112 'secret' => $secret_key,
105 113 ],
106 114 ]);
107 -
115 +
108 116 $result = [
109 117 'success' => false,
110 118 'code' => 200,
111 119 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -130,8 +138,9 @@
130 138 'NoSuchBucket',
131 139 'AllAccessDisabled',
132 140 'AuthorizationHeaderMalformed',
133 141 'PermanentRedirect',
142 + 'InvalidBucketName',
134 143 ];
135 144
136 145 if (in_array($code, $validErrors)) {
137 146 // If we reach here, the credentials are valid
@@ -145,11 +154,30 @@
145 154
146 155 if($result['success'] == false) {
147 156 return $result;
148 157 }
149 - $result['buckets_data']['buckets'] = [];
150 - $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
151 - $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 + }
152 180 return $result;
153 181 } catch (S3Exception $ex) {
154 182 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
155 183 } catch (Exception $ex) {
@@ -163,15 +191,15 @@
163 191 * Verify Bucket
164 192 * @since 1.0.0
165 193 * @return boolean
166 194 */
167 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name){
168 - if (
169 - isset($region) && !empty($region) &&
170 - isset($access_key) && !empty($access_key) &&
171 - isset($secret_key) && !empty($secret_key) &&
172 - isset($bucket_name) && !empty($bucket_name)
173 - ) {
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])) {
174 202 try {
175 203 $endpoint = $this->get_domain($region);
176 204
177 205 $DOClient = new S3Client([
@@ -221,10 +249,15 @@
221 249 * Create Bucket
222 250 * @since 1.0.0
223 251 * @return boolean
224 252 */
225 - public function createBucket($access_key, $secret_key, $region, $bucket_name){
226 - 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])) {
227 260 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
228 261 }
229 262
230 263 try {
@@ -278,10 +311,17 @@
278 311
279 312
280 313 /**
281 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
282 322 */
283 - private function putBucketPolicy($bucket, $DOClient = false) {
323 + private function putBucketPolicy($bucket, $DOClient = false, $private_prefix = '') {
284 324 if($DOClient == false) {
285 325 $DOClient = $this->DOClient;
286 326 }
287 327
@@ -286,54 +326,91 @@
286 326 }
287 327
288 328 if(empty($bucket)) return false;
289 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 +
290 364 $policy = json_encode([
291 - "Version" => "2012-10-17",
292 - "Statement" => [
293 - [
294 - "Effect" => "Allow",
295 - "Principal" => "*",
296 - "Action" => [
297 - "s3:DeleteObjectTagging",
298 - "s3:ListBucketMultipartUploads",
299 - "s3:DeleteObjectVersion",
300 - "s3:ListBucket",
301 - "s3:DeleteObjectVersionTagging",
302 - "s3:GetBucketAcl",
303 - "s3:ListMultipartUploadParts",
304 - "s3:PutObject",
305 - "s3:GetObjectAcl",
306 - "s3:GetObject",
307 - "s3:AbortMultipartUpload",
308 - "s3:DeleteObject",
309 - "s3:GetBucketLocation",
310 - "s3:PutObjectAcl",
311 - "s3:putBucketOwnershipControls",
312 - "s3:putBucketPolicy"
313 - ],
314 - "Resource" => [
315 - "arn:aws:s3:::$bucket/*",
316 - "arn:aws:s3:::$bucket"
317 - ]
318 - ]
319 - ]
365 + "Version" => "2012-10-17",
366 + "Statement" => [$statement]
320 367 ]);
321 368
322 - // Add bucket policy
323 - $DOClient->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
369 + try {
370 + // Add bucket policy
371 + $DOClient->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
324 372
325 - return true;
373 + return true;
374 + } catch (AwsException $ex) {
375 + return false; // Handle AWS specific exceptions
376 + } catch (S3Exception $ex) {
377 + return false; // Handle S3 specific exceptions
378 + } catch (Exception $ex) {
379 + return false; // Handle general exceptions
380 + }
326 381 }
327 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 + }
328 392
393 + $ok = $this->putBucketPolicy($this->bucket_name, $this->DOClient, $private_prefix);
329 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 +
330 402 /**
331 403 * Check Bucket Write Permission
332 404 * @since 1.0.0
333 405 */
334 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name){
335 - 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])) {
336 413 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
337 414 }
338 415
339 416 try {
@@ -351,9 +428,9 @@
351 428 'secret' => $secret_key,
352 429 ],
353 430 ]);
354 431
355 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
432 + $object_key = Utils::get_permission_check_object_key();
356 433
357 434
358 435 // Create a dummy object to check write permission
359 436 $DOClient->putObject([
@@ -361,9 +438,9 @@
361 438 'Key' => $object_key,
362 439 'Body' => 'This is a test object to check write permission.',
363 440 ]);
364 441 // Check if the object was created successfully
365 - if ($DOClient->doesObjectExist($bucket_name, $object_key)) {
442 + if ($this->exists($object_key, $bucket_name, $DOClient)) {
366 443 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
367 444 } else {
368 445 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
369 446 }
@@ -382,10 +459,15 @@
382 459 /**
383 460 * Check Bucket Delete Permission
384 461 * @since 1.0.0
385 462 */
386 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name){
387 - 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])) {
388 470 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
389 471 }
390 472
391 473 try {
@@ -403,9 +485,9 @@
403 485 'secret' => $secret_key,
404 486 ],
405 487 ]);
406 488
407 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
489 + $object_key = Utils::get_permission_check_object_key();
408 490
409 491 // Create a dummy object to check dlete permission
410 492 $DOClient->deleteObject([
411 493 'Bucket' => $bucket_name,
@@ -412,9 +494,9 @@
412 494 'Key' => $object_key,
413 495 ]);
414 496
415 497 // Check if the object was created successfully
416 - if (!$DOClient->doesObjectExist($bucket_name, $object_key)) {
498 + if (!$this->exists($object_key, $bucket_name, $DOClient)) {
417 499 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
418 500 } else {
419 501 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
420 502 }
@@ -427,9 +509,91 @@
427 509 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
428 510 }
429 511 }
430 512
513 +
431 514 /**
515 + * Check Bucket Read Permission
516 + * @since 1.2.4
517 + */
518 + public function verifyObjectReadPermission() {
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()];
527 + }
528 +
529 + try {
530 + $object_key = Utils::get_permission_check_object_key();
531 +
532 + // Check if the object was created successfully
533 + if (!$this->exists($object_key)) {
534 + // Create a dummy object to check write permission
535 + $this->DOClient->putObject([
536 + 'Bucket' => $this->bucket_name,
537 + 'Key' => $object_key,
538 + 'Body' => 'This is a test object to check permission.',
539 + 'ContentType' => 'text/plain',
540 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
541 + ]);
542 + }
543 +
544 +
545 + $url = $this->generate_file_url($object_key);
546 + $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
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) {
556 + $result['status'] = true;
557 + $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
558 + } else if ($status_code === 403) {
559 + $result['status'] = false;
560 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
561 + $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
562 + } else {
563 + $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
564 + }
565 + } else if ($status_code === 404) {
566 + $result['status'] = false;
567 + $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
568 + } else if ($status_code === 500) {
569 + $result['status'] = false;
570 + $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
571 + } else {
572 + $result['status'] = false;
573 + $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
574 + }
575 +
576 + $this->deleteSingle($object_key);
577 + return [
578 + 'message' => $result['message'],
579 + 'code' => 200,
580 + 'success' => $result['status'],
581 + 'lastChecked' => $result['lastChecked'],
582 + ];
583 + } catch (AwsException $ex) {
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()];
586 + } catch (S3Exception $ex) {
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()];
589 + } catch (Exception $ex) {
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()];
592 + }
593 + }
594 +
595 + /**
432 596 * isConfigured Function To Identify the congfigurations are correct
433 597 * @since 1.0.0
434 598 */
435 599 public function isConfigured(){
@@ -434,19 +598,33 @@
434 598 */
435 599 public function isConfigured(){
436 600 if ($this->DOClient) {
437 601 try {
438 - $buckets = $DOClient->listBuckets();
439 - if(!empty($buckets)){
440 - foreach($buckets as $bucket) {
441 - if ($bucket['Name']==$this->bucket_name) {
442 - return true;
443 - }
444 - }
445 - }
446 - return false;
447 - } catch (AwsException $ex) {
448 - return false;
602 + $this->DOClient->listObjectsV2([
603 + 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
604 + ]);
605 +
606 + // If we reach here, the credentials are valid
607 + return true;
608 + } catch (AwsException $e) {
609 + $code = $e->getAwsErrorCode();
610 +
611 + $validErrors = [
612 + 'AccessDenied',
613 + 'NoSuchBucket',
614 + 'AllAccessDisabled',
615 + 'AuthorizationHeaderMalformed',
616 + 'PermanentRedirect',
617 + 'InvalidBucketName',
618 + ];
619 +
620 + if (in_array($code, $validErrors)) {
621 + // If we reach here, the credentials are valid
622 + return true;
623 + } else {
624 + // If we reach here, the credentials are not valid
625 + return false;
626 + }
449 627 }
450 628 }
451 629 return false;
452 630 }
@@ -457,8 +635,9 @@
457 635 *
458 636 */
459 637 public function toPrivate($key) {
460 638 if(!$key) return false;
639 + if(!$this->DOClient) return false;
461 640 try {
462 641 $this->DOClient->putObjectAcl([
463 642 'Bucket' => $this->bucket_name,
464 643 'Key' => $key,
@@ -467,9 +646,8 @@
467 646 return true;
468 647 } catch (AwsException $ex) {
469 648 return false;
470 649 }
471 - return false;
472 650 }
473 651
474 652
475 653
@@ -475,23 +653,23 @@
475 653
476 654 /**
477 655 * Make Object Public
478 656 * @since 1.0.0
479 - *
657 + *
480 658 */
481 659 public function toPublic($key) {
482 660 if(!$key) return false;
661 + if(!$this->DOClient) return false;
483 662 try {
484 663 $this->DOClient->putObjectAcl([
485 664 'Bucket' => $this->bucket_name,
486 665 'Key' => $key,
487 666 'ACL' => 'public-read'
488 - ]);
667 + ]);
489 668 return true;
490 669 } catch (AwsException $ex) {
491 670 return false;
492 671 }
493 - return false;
494 672 }
495 673
496 674
497 675
@@ -498,112 +676,208 @@
498 676 /**
499 677 * Check the object exist
500 678 * @since 1.1.8
501 679 */
502 - public function exists($key) {
680 + public function exists($key, $bucket_name = '', $client = null) {
503 681 if(!$key) return false;
504 682
505 - if($this->DOClient->doesObjectExist($this->bucket_name, $key)) {
506 - return true;
683 + try {
684 + $client = $client ?? $this->DOClient;
685 + $bucket_name = !empty($bucket_name) ? $bucket_name : $this->bucket_name;
686 + if($client->doesObjectExistV2($bucket_name, $key)) {
687 + return true;
688 + }
689 + return false;
690 + } catch (AwsException $ex) {
691 + return false;
507 692 }
508 -
509 - return false;
693 + catch (S3Exception $ex) {
694 + return false;
695 + } catch (Exception $ex) {
696 + return false;
697 + }
510 698 }
511 699
512 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 + /**
513 755 * Upload Single
514 756 * @since 1.0.0
515 757 * @return boolean
516 758 */
517 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
518 - $result = array();
759 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
519 760 if (
520 - isset($media_absolute_path) && !empty($media_absolute_path) &&
521 - isset($media_path) && !empty($media_path)
761 + isset($absolute_source_path) && !empty($absolute_source_path) &&
762 + isset($relative_source_path) && !empty($relative_source_path)
522 763 ) {
523 - $file_name = wp_basename( $media_path );
764 + $file_name = wp_basename( $relative_source_path );
524 765 if ($file_name) {
525 - $upload_path = Utils::generate_object_key($media_path, $prefix);
526 -
527 - // Decide Multipart upload or normal put object
528 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
529 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
530 - try {
531 - $upload = $this->DOClient->putObject([
532 - 'Bucket' => $this->bucket_name,
533 - 'Key' => $upload_path,
534 - 'Body' => fopen($media_absolute_path, 'r'),
535 - ]);
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 + }
536 788
537 - $result = array(
538 - 'success' => true,
539 - 'code' => 200,
540 - 'file_url' => $this->generate_file_url($upload_path),
541 - 'key' => $upload_path,
542 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
543 - );
544 - } catch (AwsException $e) {
545 - $result = array(
546 - 'success' => false,
547 - 'code' => 200,
548 - 'message' => $e->getMessage()
549 - );
550 - }
551 - } else {
552 - $multiUploader = new MultipartUploader($this->DOClient, $media_absolute_path, [
553 - 'bucket' => $this->bucket_name,
554 - 'key' => $upload_path,
555 - ]);
556 -
557 - try {
558 - do {
559 - try {
560 - $uploaded = $multiUploader->upload();
561 - } catch (MultipartUploadException $e) {
562 - $multiUploader = new MultipartUploader($this->DOClient, $media_absolute_path, [
563 - 'state' => $e->getState(),
564 - ]);
565 - }
566 - } 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 + }
567 796
568 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
569 - $result = array(
570 - 'success' => true,
571 - 'code' => 200,
572 - 'file_url' => $this->generate_file_url($upload_path),
573 - 'key' => $upload_path,
574 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
575 - );
576 - } else {
577 - $result = array(
578 - 'success' => false,
579 - 'code' => 200,
580 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
581 - );
582 - }
583 - } catch (MultipartUploadException $e) {
584 - $result = array(
585 - 'success' => false,
586 - 'code' => 200,
587 - 'message' => $e->getMessage()
588 - );
589 - }
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 + ];
590 864 }
591 - } else {
592 - $result = array(
865 + $options = ['state' => $e->getState()];
866 + } catch (AwsException $e) {
867 + return [
593 868 'success' => false,
594 869 'code' => 200,
595 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
596 - );
870 + 'message' => $e->getMessage()
871 + ];
872 + } catch (Exception $e) {
873 + return [
874 + 'success' => false,
875 + 'code' => 200,
876 + 'message' => $e->getMessage()
877 + ];
597 878 }
598 - } else {
599 - $result = array(
600 - 'success' => false,
601 - 'code' => 200,
602 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
603 - );
604 879 }
605 - return $result;
606 880 }
607 881
608 882 /**
609 883 * Save object to server
@@ -609,8 +883,9 @@
609 883 * Save object to server
610 884 * @since 1.0.0
611 885 */
612 886 public function object_to_server($key, $save_path) {
887 + if(!$this->DOClient) return false;
613 888 try {
614 889 $getObject = $this->DOClient->GetObject([
615 890 'Bucket' => $this->bucket_name,
616 891 'Key' => $key,
@@ -624,10 +899,155 @@
624 899 }
625 900 return false;
626 901 }
627 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 + }
628 920
629 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 + /**
630 1050 * Delete Single
631 1051 * @since 1.0.0
632 1052 * @return boolean
633 1053 */
@@ -632,8 +1052,15 @@
632 1052 * @return boolean
633 1053 */
634 1054 public function deleteSingle($key) {
635 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 + }
636 1063 if (isset($key) && !empty($key)) {
637 1064 try {
638 1065 $this->DOClient->deleteObject([
639 1066 'Bucket' => $this->bucket_name,
@@ -639,9 +1066,9 @@
639 1066 'Bucket' => $this->bucket_name,
640 1067 'Key' => $key
641 1068 ]);
642 1069
643 - if (!$this->DOClient->doesObjectExist($this->bucket_name, $key)) {
1070 + if (!$this->exists($key)) {
644 1071 $result = array(
645 1072 'success' => true,
646 1073 'code' => 200,
647 1074 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -670,14 +1097,21 @@
670 1097 return $result;
671 1098 }
672 1099
673 1100 /**
674 - * get presigned URL
1101 + * get private URL
675 1102 * @since 1.0.0
676 1103 * @return boolean
677 1104 */
678 - public function get_presigned_url($key) {
1105 + public function get_private_url($key) {
679 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 + }
680 1114 if (isset($key) && !empty($key)) {
681 1115 try {
682 1116 $cmd = $this->DOClient->getCommand('GetObject', [
683 1117 'Bucket' => $this->bucket_name,
@@ -683,24 +1117,24 @@
683 1117 'Bucket' => $this->bucket_name,
684 1118 'Key' => $key
685 1119 ]);
686 1120
687 - $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;
688 1122
689 1123 $request = $this->DOClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
690 1124
691 - if ($presignedUrl = (string)$request->getUri()) {
1125 + if ($privateUrl = (string)$request->getUri()) {
692 1126 $result = array(
693 1127 'success' => true,
694 1128 'code' => 200,
695 - 'file_url' => $presignedUrl,
696 - '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')
697 1131 );
698 1132 } else {
699 1133 $result = array(
700 1134 'success' => false,
701 1135 'code' => 200,
702 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1136 + 'message' => esc_html__('Error getting Private URL', 'media-cloud-sync')
703 1137 );
704 1138 }
705 1139 } catch (AwsException $e) {
706 1140 $result = array(
@@ -721,9 +1155,9 @@
721 1155
722 1156 /**
723 1157 * Generate file URL
724 1158 */
725 - private function generate_file_url($key){
1159 + public function generate_file_url($key){
726 1160 $domain = $this->get_domain();
727 1161
728 1162 return apply_filters('wpmcs_generate_do_file_url',
729 1163 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -730,8 +1164,17 @@
730 1164 $domain,
731 1165 $this->bucket_name,
732 1166 $key
733 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);
734 1177 }
735 1178
736 1179 /**
737 1180 * Get domain URL