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
media-cloud-sync / includes / base / services / docean.php

docean.php in Media Cloud Sync 1.4.1, at includes/base/services/docean.php

1,189 lines 48.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Dudlewebs\WPMCS;
3
4 defined('ABSPATH') || exit;
5
6 // Libraries
7 use Dudlewebs\WPMCS\s3\Aws\S3\S3Client;
8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 use Dudlewebs\WPMCS\s3\Aws\Command;
14 use Exception;
15
16 class DOcean {
17 private $assets_url;
18 private $version;
19 private $token;
20
21 protected $config;
22 protected $bucketConfig;
23 protected $settings;
24 protected $credentials;
25 protected $bucket_name;
26 protected $cdnConfig;
27
28 public $service = 'docean';
29 public $DOClient = false;
30
31 /**
32 * Admin constructor.
33 * @since 1.0.0
34 */
35 public function __construct($credentials = null) {
36 $this->assets_url = WPMCS_ASSETS_URL;
37 $this->version = WPMCS_VERSION;
38 $this->token = WPMCS_TOKEN;
39
40 // Initialize setup
41 $this->init($credentials);
42 }
43
44 /**
45 * Initialise Client
46 *
47 * @param array|null $credentials Optional explicit credentials; falls back to
48 * Utils::get_credentials() when omitted.
49 */
50 public function init($credentials = null) {
51 $this->settings = Utils::get_settings();
52 $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
54 ? $this->credentials['config']
55 : [];
56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
57 ? $this->credentials['bucketConfig']
58 : [];
59 $this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
60 ? $this->bucketConfig['bucket_name']
61 : '';
62 $this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
63 ? $this->credentials['cdn']
64 : [];
65
66 if (
67 isset($this->config['region']) && !empty($this->config['region']) &&
68 isset($this->config['access_key']) && !empty($this->config['access_key']) &&
69 isset($this->config['secret_key']) && !empty($this->config['secret_key'])
70 ) {
71 $endpoint = $this->get_domain();
72
73 $this->DOClient = new S3Client([
74 'version' => '2006-03-01',
75 'region' => $this->config['region'],
76 'endpoint' => $endpoint, // DigitalOcean Spaces requires a custom endpoint
77 'use_accelerate_endpoint' => false,
78 'use_path_style_endpoint' => true, // DigitalOcean Spaces often requires path-style endpoints
79 'use_aws_shared_config_files' => false,
80 'credentials' => [
81 'key' => $this->config['access_key'],
82 'secret' => $this->config['secret_key'],
83 ],
84 ]);
85 }
86
87 }
88
89
90 /**
91 * Verify Credentials
92 * @since 1.0.0
93 * @return boolean
94 */
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])) {
100 try {
101 $endpoint = $this->get_domain($region);
102
103 $DOClient = new S3Client([
104 'version' => '2006-03-01',
105 'region' => $region,
106 'endpoint' => $endpoint,
107 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
108 'use_accelerate_endpoint' => false,
109 'use_aws_shared_config_files' => false,
110 'credentials' => [
111 'key' => $access_key,
112 'secret' => $secret_key,
113 ],
114 ]);
115
116 $result = [
117 'success' => false,
118 'code' => 200,
119 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
120 ];
121
122 try {
123 $DOClient->listObjectsV2([
124 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
125 ]);
126
127 // If we reach here, the credentials are valid
128 $result = [
129 'success' => true,
130 'code' => 200,
131 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
132 ];
133 } catch (AwsException $e) {
134 $code = $e->getAwsErrorCode();
135
136 $validErrors = [
137 'AccessDenied',
138 'NoSuchBucket',
139 'AllAccessDisabled',
140 'AuthorizationHeaderMalformed',
141 'PermanentRedirect',
142 'InvalidBucketName',
143 ];
144
145 if (in_array($code, $validErrors)) {
146 // If we reach here, the credentials are valid
147 $result = [
148 'success' => true,
149 'code' => 200,
150 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
151 ];
152 }
153 }
154
155 if($result['success'] == false) {
156 return $result;
157 }
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 }
180 return $result;
181 } catch (S3Exception $ex) {
182 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
183 } catch (Exception $ex) {
184 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
185 }
186 }
187 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
188 }
189
190 /**
191 * Verify Bucket
192 * @since 1.0.0
193 * @return boolean
194 */
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])) {
202 try {
203 $endpoint = $this->get_domain($region);
204
205 $DOClient = new S3Client([
206 'version' => '2006-03-01',
207 'region' => $region,
208 'endpoint' => $endpoint,
209 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
210 'use_accelerate_endpoint' => false,
211 'use_aws_shared_config_files' => false,
212 'credentials' => [
213 'key' => $access_key,
214 'secret' => $secret_key,
215 ],
216 ]);
217
218 //get S3 object
219 $bucket_found = false;
220 try {
221 $DOClient->getObject([
222 'Bucket' => $bucket_name,
223 'Key' => $this->token . '_dummy-object-for-bucket-exist-check'
224 ]);
225 $bucket_found = true;
226 } catch (AwsException $e) {
227 $code = $e->getAwsErrorCode();
228 if ($code === 'NoSuchKey') {
229 $bucket_found = true;
230 }
231 }
232
233 if($bucket_found) {
234 return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
235 } else {
236 return array('message' => esc_html__("Bucket choosen does not exist / does not have read permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
237 }
238 }
239 catch (S3Exception $ex) {
240 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
241 } catch (Exception $ex) {
242 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
243 }
244 }
245 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
246 }
247
248 /**
249 * Create Bucket
250 * @since 1.0.0
251 * @return boolean
252 */
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])) {
260 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
261 }
262
263 try {
264 $endpoint = $this->get_domain($region);
265
266 $DOClient = new S3Client([
267 'version' => '2006-03-01',
268 'region' => $region,
269 'endpoint' => $endpoint,
270 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
271 'use_accelerate_endpoint' => false,
272 'use_aws_shared_config_files' => false,
273 'credentials' => [
274 'key' => $access_key,
275 'secret' => $secret_key,
276 ],
277 ]);
278
279 // Create Bucket
280 $DOClient->createBucket([
281 'Bucket' => $bucket_name,
282 ]);
283
284 // Optionally wait for bucket existence (recommended)
285 $DOClient->waitUntil('BucketExists', ['Bucket' => $bucket_name]);
286
287 try {
288 $this->putBucketPolicy($bucket_name, $DOClient);
289
290 return [
291 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
292 'data' => [
293 'Name' => $bucket_name,
294 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
295 ],
296 'code' => 200,
297 'success' => true,
298 ];
299
300 } catch (AwsException $ex) {
301 return ['message' => esc_html__('Bucket created. But the following error happened while setting the public access,', 'media-cloud-sync') . ' ' . $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
302 }
303 } catch (AwsException $ex) {
304 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
305 } catch (S3Exception $ex) {
306 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
307 } catch (Exception $ex) {
308 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
309 }
310 }
311
312
313 /**
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
322 */
323 private function putBucketPolicy($bucket, $DOClient = false, $private_prefix = '') {
324 if($DOClient == false) {
325 $DOClient = $this->DOClient;
326 }
327
328 if(empty($bucket)) return false;
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
364 $policy = json_encode([
365 "Version" => "2012-10-17",
366 "Statement" => [$statement]
367 ]);
368
369 try {
370 // Add bucket policy
371 $DOClient->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
372
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 }
381 }
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 }
392
393 $ok = $this->putBucketPolicy($this->bucket_name, $this->DOClient, $private_prefix);
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
402 /**
403 * Check Bucket Write Permission
404 * @since 1.0.0
405 */
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])) {
413 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
414 }
415
416 try {
417 $endpoint = $this->get_domain($region);
418
419 $DOClient = new S3Client([
420 'version' => '2006-03-01',
421 'region' => $region,
422 'endpoint' => $endpoint,
423 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
424 'use_accelerate_endpoint' => false,
425 'use_aws_shared_config_files' => false,
426 'credentials' => [
427 'key' => $access_key,
428 'secret' => $secret_key,
429 ],
430 ]);
431
432 $object_key = Utils::get_permission_check_object_key();
433
434
435 // Create a dummy object to check write permission
436 $DOClient->putObject([
437 'Bucket' => $bucket_name,
438 'Key' => $object_key,
439 'Body' => 'This is a test object to check write permission.',
440 ]);
441 // Check if the object was created successfully
442 if ($this->exists($object_key, $bucket_name, $DOClient)) {
443 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
444 } else {
445 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
446 }
447
448 } catch (AwsException $ex) {
449 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
450 } catch (S3Exception $ex) {
451 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
452 } catch (Exception $ex) {
453 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
454 }
455 }
456
457
458
459 /**
460 * Check Bucket Delete Permission
461 * @since 1.0.0
462 */
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])) {
470 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
471 }
472
473 try {
474 $endpoint = $this->get_domain($region);
475
476 $DOClient = new S3Client([
477 'version' => '2006-03-01',
478 'region' => $region,
479 'endpoint' => $endpoint,
480 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
481 'use_accelerate_endpoint' => false,
482 'use_aws_shared_config_files' => false,
483 'credentials' => [
484 'key' => $access_key,
485 'secret' => $secret_key,
486 ],
487 ]);
488
489 $object_key = Utils::get_permission_check_object_key();
490
491 // Create a dummy object to check dlete permission
492 $DOClient->deleteObject([
493 'Bucket' => $bucket_name,
494 'Key' => $object_key,
495 ]);
496
497 // Check if the object was created successfully
498 if (!$this->exists($object_key, $bucket_name, $DOClient)) {
499 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
500 } else {
501 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
502 }
503
504 } catch (AwsException $ex) {
505 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
506 } catch (S3Exception $ex) {
507 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
508 } catch (Exception $ex) {
509 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
510 }
511 }
512
513
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 /**
596 * isConfigured Function To Identify the congfigurations are correct
597 * @since 1.0.0
598 */
599 public function isConfigured(){
600 if ($this->DOClient) {
601 try {
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 }
627 }
628 }
629 return false;
630 }
631
632 /**
633 * Make Object Private
634 * @since 1.0.0
635 *
636 */
637 public function toPrivate($key) {
638 if(!$key) return false;
639 if(!$this->DOClient) return false;
640 try {
641 $this->DOClient->putObjectAcl([
642 'Bucket' => $this->bucket_name,
643 'Key' => $key,
644 'ACL' => 'private'
645 ]);
646 return true;
647 } catch (AwsException $ex) {
648 return false;
649 }
650 }
651
652
653
654 /**
655 * Make Object Public
656 * @since 1.0.0
657 *
658 */
659 public function toPublic($key) {
660 if(!$key) return false;
661 if(!$this->DOClient) return false;
662 try {
663 $this->DOClient->putObjectAcl([
664 'Bucket' => $this->bucket_name,
665 'Key' => $key,
666 'ACL' => 'public-read'
667 ]);
668 return true;
669 } catch (AwsException $ex) {
670 return false;
671 }
672 }
673
674
675
676 /**
677 * Check the object exist
678 * @since 1.1.8
679 */
680 public function exists($key, $bucket_name = '', $client = null) {
681 if(!$key) return false;
682
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;
692 }
693 catch (S3Exception $ex) {
694 return false;
695 } catch (Exception $ex) {
696 return false;
697 }
698 }
699
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 /**
755 * Upload Single
756 * @since 1.0.0
757 * @return boolean
758 */
759 public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
760 if (
761 isset($absolute_source_path) && !empty($absolute_source_path) &&
762 isset($relative_source_path) && !empty($relative_source_path)
763 ) {
764 $file_name = wp_basename( $relative_source_path );
765 if ($file_name) {
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 }
788
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 }
796
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 ];
864 }
865 $options = ['state' => $e->getState()];
866 } catch (AwsException $e) {
867 return [
868 'success' => false,
869 'code' => 200,
870 'message' => $e->getMessage()
871 ];
872 } catch (Exception $e) {
873 return [
874 'success' => false,
875 'code' => 200,
876 'message' => $e->getMessage()
877 ];
878 }
879 }
880 }
881
882 /**
883 * Save object to server
884 * @since 1.0.0
885 */
886 public function object_to_server($key, $save_path) {
887 if(!$this->DOClient) return false;
888 try {
889 $getObject = $this->DOClient->GetObject([
890 'Bucket' => $this->bucket_name,
891 'Key' => $key,
892 'SaveAs' => $save_path
893 ]);
894 if (file_exists($save_path)) {
895 return true;
896 }
897 } catch (AwsException $e) {
898 return false;
899 }
900 return false;
901 }
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 }
920
921 /**
922 * Deletes the live object, then best-effort purges every historical version too — a
923 * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
924 * versions (and the storage they use) behind at the old key. The live delete happens
925 * unconditionally first: DigitalOcean Spaces doesn't support object versioning at all,
926 * so the version-listing part below simply fails there (caught, non-fatal) — the object
927 * must still end up gone either way, which is why it can't be the only delete call.
928 * @since 1.3.14
929 */
930 public function purge_all_versions($key) {
931 if (!$this->DOClient) {
932 return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
933 }
934
935 try {
936 $this->DOClient->deleteObject([
937 'Bucket' => $this->bucket_name,
938 'Key' => $key,
939 ]);
940 } catch (AwsException $e) {
941 return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
942 }
943
944 // Best-effort only from here — Spaces doesn't support version listing at all, so
945 // this always no-ops there; the live object above is already gone regardless.
946 try {
947 $objects = [];
948 $marker = null;
949 do {
950 $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
951 if ($marker) {
952 $args['KeyMarker'] = $marker['key'];
953 $args['VersionIdMarker'] = $marker['version'];
954 }
955 $result = $this->DOClient->listObjectVersions($args);
956 foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
957 if (($version['Key'] ?? null) === $key) {
958 $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
959 }
960 }
961 $marker = !empty($result['IsTruncated'])
962 ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
963 : null;
964 } while ($marker);
965
966 foreach (array_chunk($objects, 1000) as $chunk) {
967 $this->DOClient->deleteObjects([
968 'Bucket' => $this->bucket_name,
969 'Delete' => ['Objects' => $chunk],
970 ]);
971 }
972 } catch (AwsException $e) {
973 // Version history cleanup unsupported/failed — not fatal, live object is gone.
974 }
975
976 return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
977 }
978
979 /**
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 /**
1050 * Delete Single
1051 * @since 1.0.0
1052 * @return boolean
1053 */
1054 public function deleteSingle($key) {
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 }
1063 if (isset($key) && !empty($key)) {
1064 try {
1065 $this->DOClient->deleteObject([
1066 'Bucket' => $this->bucket_name,
1067 'Key' => $key
1068 ]);
1069
1070 if (!$this->exists($key)) {
1071 $result = array(
1072 'success' => true,
1073 'code' => 200,
1074 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
1075 );
1076 } else {
1077 $result = array(
1078 'success' => false,
1079 'code' => 200,
1080 'message' => esc_html__('File not deleted', 'media-cloud-sync')
1081 );
1082 }
1083 } catch (AwsException $e) {
1084 $result = array(
1085 'success' => false,
1086 'code' => 200,
1087 'message' => $e->getMessage()
1088 );
1089 }
1090 } else {
1091 $result = array(
1092 'success' => false,
1093 'code' => 200,
1094 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1095 );
1096 }
1097 return $result;
1098 }
1099
1100 /**
1101 * get private URL
1102 * @since 1.0.0
1103 * @return boolean
1104 */
1105 public function get_private_url($key) {
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 }
1114 if (isset($key) && !empty($key)) {
1115 try {
1116 $cmd = $this->DOClient->getCommand('GetObject', [
1117 'Bucket' => $this->bucket_name,
1118 'Key' => $key
1119 ]);
1120
1121 $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
1122
1123 $request = $this->DOClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1124
1125 if ($privateUrl = (string)$request->getUri()) {
1126 $result = array(
1127 'success' => true,
1128 'code' => 200,
1129 'file_url' => $privateUrl,
1130 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
1131 );
1132 } else {
1133 $result = array(
1134 'success' => false,
1135 'code' => 200,
1136 'message' => esc_html__('Error getting Private URL', 'media-cloud-sync')
1137 );
1138 }
1139 } catch (AwsException $e) {
1140 $result = array(
1141 'success' => false,
1142 'code' => 200,
1143 'message' => $e->getMessage()
1144 );
1145 }
1146 } else {
1147 $result = array(
1148 'success' => false,
1149 'code' => 200,
1150 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1151 );
1152 }
1153 return $result;
1154 }
1155
1156 /**
1157 * Generate file URL
1158 */
1159 public function generate_file_url($key){
1160 $domain = $this->get_domain();
1161
1162 return apply_filters('wpmcs_generate_do_file_url',
1163 $domain . '/' . $this->bucket_name . '/' . $key,
1164 $domain,
1165 $this->bucket_name,
1166 $key
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);
1177 }
1178
1179 /**
1180 * Get domain URL
1181 */
1182 public function get_domain($region = '') {
1183 if(empty($region)) {
1184 $region = isset($this->config['region']) ? $this->config['region'] : '';
1185 }
1186 return "https://{$region}.digitaloceanspaces.com";
1187 }
1188
1189 }