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/s3.php +715 -219 1.2.31.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 S3 {
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 = 's3';
26 29 public $s3Client = 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']) &&
@@ -79,14 +88,14 @@
79 88 * Verify Credentials
80 89 * @since 1.0.0
81 90 * @return boolean
82 91 */
83 - public function verifyCredentials($access_key, $secret_key, $region){
84 - if (
85 - isset($region) && !empty($region) &&
86 - isset($access_key) && !empty($access_key) &&
87 - isset($secret_key) && !empty($secret_key)
88 - ) {
92 + public function verifyCredentials($config = []) {
93 + $region = isset($config['region']) ? $config['region'] : '';
94 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
95 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
96 +
97 + if (!Service::has_missing_fields([$region, $access_key, $secret_key])) {
89 98 try {
90 99 $s3Client = new S3Client([
91 100 'version' => '2006-03-01',
92 101 'region' => $region,
@@ -122,8 +131,9 @@
122 131 'NoSuchBucket',
123 132 'AllAccessDisabled',
124 133 'AuthorizationHeaderMalformed',
125 134 'PermanentRedirect',
135 + 'InvalidBucketName',
126 136 ];
127 137
128 138 if (in_array($code, $validErrors)) {
129 139 // If we reach here, the credentials are valid
@@ -176,15 +186,16 @@
176 186 * Verify Bucket Exist
177 187 * @since 1.0.0
178 188 * @return boolean
179 189 */
180 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration=false){
181 - if (
182 - isset($region) && !empty($region) &&
183 - isset($access_key) && !empty($access_key) &&
184 - isset($secret_key) && !empty($secret_key) &&
185 - isset($bucket_name) && !empty($bucket_name)
186 - ) {
190 + public function verifyBucketExist( $config = [], $bucketConfig = [] ) {
191 + $region = isset($config['region']) ? $config['region'] : '';
192 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
193 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
194 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
195 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
196 +
197 + if (!Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
187 198 try {
188 199 $s3Client = new S3Client([
189 200 'version' => '2006-03-01',
190 201 'region' => $region,
@@ -230,10 +241,16 @@
230 241 * Create Bucket
231 242 * @since 1.0.0
232 243 * @return boolean
233 244 */
234 - public function createBucket($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
235 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
245 + public function createBucket( $config = [], $bucketConfig = [] ) {
246 + $region = isset($config['region']) ? $config['region'] : '';
247 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
248 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
249 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
250 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
251 +
252 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
236 253 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
237 254 }
238 255
239 256 try {
@@ -298,10 +315,16 @@
298 315 /**
299 316 * Check Bucket Write Permission
300 317 * @since 1.0.0
301 318 */
302 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
303 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
319 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
320 + $region = isset($config['region']) ? $config['region'] : '';
321 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
322 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
323 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
324 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
325 +
326 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
304 327 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
305 328 }
306 329
307 330 try {
@@ -317,9 +340,9 @@
317 340 ];
318 341
319 342 $s3Client = new S3Client($s3ClientConfig);
320 343
321 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
344 + $object_key = Utils::get_permission_check_object_key();
322 345
323 346
324 347 // Create a dummy object to check write permission
325 348 $s3Client->putObject([
@@ -327,9 +350,9 @@
327 350 'Key' => $object_key,
328 351 'Body' => 'This is a test object to check write permission.',
329 352 ]);
330 353 // Check if the object was created successfully
331 - if ($s3Client->doesObjectExist($bucket_name, $object_key)) {
354 + if ($this->exists($object_key, $bucket_name, $s3Client)) {
332 355 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
333 356 } else {
334 357 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
335 358 }
@@ -348,10 +371,16 @@
348 371 /**
349 372 * Check Bucket Delete Permission
350 373 * @since 1.0.0
351 374 */
352 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
353 - if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
375 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
376 + $region = isset($config['region']) ? $config['region'] : '';
377 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
378 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
379 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
380 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
381 +
382 + if (Service::has_missing_fields([$region, $access_key, $secret_key, $bucket_name])) {
354 383 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
355 384 }
356 385
357 386 try {
@@ -367,9 +396,9 @@
367 396 ];
368 397
369 398 $s3Client = new S3Client($s3ClientConfig);
370 399
371 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
400 + $object_key = Utils::get_permission_check_object_key();
372 401
373 402 // Create a dummy object to check dlete permission
374 403 $s3Client->deleteObject([
375 404 'Bucket' => $bucket_name,
@@ -376,9 +405,9 @@
376 405 'Key' => $object_key,
377 406 ]);
378 407
379 408 // Check if the object was created successfully
380 - if (!$s3Client->doesObjectExist($bucket_name, $object_key)) {
409 + if (!$this->exists($object_key, $bucket_name, $s3Client)) {
381 410 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
382 411 } else {
383 412 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
384 413 }
@@ -393,15 +422,105 @@
393 422 }
394 423
395 424
396 425 /**
426 + * Check Bucket Read Permission
427 + * @since 1.2.4
428 + */
429 + public function verifyObjectReadPermission() {
430 + $result = [
431 + 'status' => false,
432 + 'message' => '',
433 + 'lastChecked' => time(),
434 + ];
435 +
436 + if (Service::has_missing_fields([$this->s3Client, $this->bucket_name])) {
437 + $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
438 + return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
439 + }
440 +
441 + try {
442 + $object_key = Utils::get_permission_check_object_key();
443 +
444 + // Check if the object was created successfully
445 + if (!$this->exists($object_key)) {
446 + // Create a dummy object to check write permission
447 + $this->s3Client->putObject([
448 + 'Bucket' => $this->bucket_name,
449 + 'Key' => $object_key,
450 + 'Body' => 'This is a test object to check permission.',
451 + 'ContentType' => 'text/plain',
452 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
453 + ]);
454 + }
455 +
456 +
457 + $url = $this->generate_file_url($object_key);
458 + $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
459 + // Never trust a cached response for this fixed, predictable URL — a stale cached
460 + // error would otherwise keep failing the check long after real access is fine.
461 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
462 + $headers = @get_headers($cdn_url, false, $no_cache_context);
463 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
464 + ? (int) $matches[1]
465 + : 0;
466 +
467 + if ($status_code === 200) {
468 + $result['status'] = true;
469 + $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
470 + } else if ($status_code === 403) {
471 + $result['status'] = false;
472 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
473 + $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
474 + } else {
475 + $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
476 + }
477 + } else if ($status_code === 404) {
478 + $result['status'] = false;
479 + $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
480 + } else if ($status_code === 500) {
481 + $result['status'] = false;
482 + $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
483 + } else {
484 + $result['status'] = false;
485 + $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
486 + }
487 +
488 + $this->deleteSingle($object_key);
489 + return [
490 + 'message' => $result['message'],
491 + 'code' => 200,
492 + 'success' => $result['status'],
493 + 'lastChecked' => $result['lastChecked'],
494 + ];
495 + } catch (AwsException $ex) {
496 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
497 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
498 + } catch (S3Exception $ex) {
499 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
500 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
501 + } catch (Exception $ex) {
502 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
503 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
504 + }
505 + }
506 +
507 +
508 + /**
397 509 * get Bucket Security Settings
398 510 */
399 - public function getBucketSecuritySettings($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
511 + public function getBucketSecuritySettings( $config = [], $bucketConfig = [] ) {
512 + $region = isset($config['region']) ? $config['region'] : '';
513 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
514 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
515 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
516 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
517 +
400 518 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
401 519 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
402 520 }
403 521
522 + $errors = [];
404 523 try {
405 524 $s3ClientConfig = [
406 525 'version' => '2006-03-01',
407 526 'region' => $region,
@@ -414,42 +533,51 @@
414 533 ];
415 534
416 535 $s3Client = new S3Client($s3ClientConfig);
417 536
418 - $publicAccessBlock = $s3Client->getPublicAccessBlock([
419 - 'Bucket' => $bucket_name,
420 - ]);
421 -
422 - $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
423 -
424 - $security = [];
425 - if (
426 - $publicAccessBlockConfig['BlockPublicAcls'] &&
427 - $publicAccessBlockConfig['IgnorePublicAcls'] &&
428 - $publicAccessBlockConfig['BlockPublicPolicy'] &&
429 - $publicAccessBlockConfig['RestrictPublicBuckets']
430 - ) {
431 - $security['block_public_access'] = true;
432 - } else {
433 - $security['block_public_access'] = false;
537 + // Check public access block configuration
538 + $security['block_public_access'] = false;
539 + try {
540 + $publicAccessBlock = $s3Client->getPublicAccessBlock([
541 + 'Bucket' => $bucket_name,
542 + ]);
543 +
544 + $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
545 + if (
546 + $publicAccessBlockConfig['BlockPublicAcls'] &&
547 + $publicAccessBlockConfig['IgnorePublicAcls'] &&
548 + $publicAccessBlockConfig['BlockPublicPolicy'] &&
549 + $publicAccessBlockConfig['RestrictPublicBuckets']
550 + ) {
551 + $security['block_public_access'] = true;
552 + }
553 + } catch (S3Exception $ex) {
554 + // If the bucket does not have public access block configuration, we assume it is not blocked
555 + $errors['block_public_access'] = $ex->getMessage();
556 + } catch (Exception $ex) {
557 + $errors['block_public_access'] = $ex->getMessage();
434 558 }
435 559
436 560
437 - $ownershipControls = $s3Client->getBucketOwnershipControls([
438 - 'Bucket' => $bucket_name,
439 - ]);
440 -
441 - $ownershipRule = $ownershipControls['OwnershipControls']['Rules'][0]['ObjectOwnership'];
442 -
443 - if ($ownershipRule === 'BucketOwnerEnforced') {
444 - $security['object_ownership_enforced'] = true;
445 - } else {
446 - $security['object_ownership_enforced'] = false;
561 + $security['object_ownership_enforced'] = false;
562 + try {
563 + $ownershipControls = $s3Client->getBucketOwnershipControls([
564 + 'Bucket' => $bucket_name,
565 + ]);
566 +
567 + $ownershipRule = $ownershipControls['OwnershipControls']['Rules'][0]['ObjectOwnership'];
568 +
569 + if ($ownershipRule === 'BucketOwnerEnforced') {
570 + $security['object_ownership_enforced'] = true;
571 + }
572 + } catch (S3Exception $ex) {
573 + $errors['object_ownership_enforced'] = $ex->getMessage();
574 + } catch (Exception $ex) {
575 + $errors['object_ownership_enforced'] = $ex->getMessage();
447 576 }
448 577
449 - return ['message' => '', 'code' => 200, 'success' => true, 'security' => $security];
450 - }
451 - catch (AwsException $ex) {
578 + return ['message' => '', 'code' => 200, 'success' => empty($errors), 'security' => $security, 'errors' => $errors];
579 + } catch (AwsException $ex) {
452 580 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
453 581 } catch (S3Exception $ex) {
454 582 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
455 583 } catch (Exception $ex) {
@@ -462,9 +590,15 @@
462 590 /**
463 591 * Change Bucket Public Access
464 592 */
465 593
466 - public function changePublicAccess($value, $access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
594 + public function changePublicAccess( $config = [], $bucketConfig = [], $value = false ) {
595 + $region = isset($config['region']) ? $config['region'] : '';
596 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
597 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
598 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
599 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
600 +
467 601 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
468 602 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
469 603 }
470 604
@@ -480,12 +614,20 @@
480 614 ],
481 615 ];
482 616
483 617 $s3Client = new S3Client($s3ClientConfig);
484 -
485 - return ['message' => '', 'code' => 200, 'success' => true, 'result' => $this->blockPublicAccess($bucket_name, $value, $s3Client)];
486 - }
487 - catch (AwsException $ex) {
618 +
619 + try {
620 + $result = $this->blockPublicAccess($bucket_name, $value, $s3Client);
621 + return ['message' => '', 'code' => 200, 'success' => true, 'result' => $result];
622 + } catch (AwsException $ex) {
623 + return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
624 + } catch (S3Exception $ex) {
625 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
626 + } catch (Exception $ex) {
627 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
628 + }
629 + } catch (AwsException $ex) {
488 630 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
489 631 } catch (S3Exception $ex) {
490 632 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
491 633 } catch (Exception $ex) {
@@ -498,9 +640,15 @@
498 640 /**
499 641 * Change Bucket Ownership
500 642 */
501 643
502 - public function changeObjectOwnership($value, $access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
644 + public function changeObjectOwnership( $config = [], $bucketConfig = [], $value = false ) {
645 + $region = isset($config['region']) ? $config['region'] : '';
646 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
647 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
648 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
649 + $transfer_acceleration = isset($bucketConfig['transfer_acceleration']) ? $bucketConfig['transfer_acceleration'] : false;
650 +
503 651 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
504 652 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
505 653 }
506 654
@@ -518,10 +666,20 @@
518 666
519 667 $s3Client = new S3Client($s3ClientConfig);
520 668
521 669 $ownership = $value ? 'BucketOwnerEnforced' : 'BucketOwnerPreferred';
670 +
671 + try {
672 + $result = $this->changeBucketOwnership( $bucket_name, $s3Client, $ownership );
673 + return ['message' => '', 'code' => 200, 'success' => true, 'result' => $result];
674 + } catch (AwsException $ex) {
675 + return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
676 + } catch (S3Exception $ex) {
677 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
678 + } catch (Exception $ex) {
679 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
680 + }
522 681
523 - return ['message' => '', 'code' => 200, 'success' => true, 'result' => $this->changeBucketOwnership( $bucket_name, $s3Client, $ownership )];
524 682 }
525 683 catch (AwsException $ex) {
526 684 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
527 685 } catch (S3Exception $ex) {
@@ -563,10 +721,18 @@
563 721 }
564 722
565 723 /**
566 724 * Add Bucket Policy
725 + *
726 + * $private_prefix, when non-empty, carves that path out of the public
727 + * grant entirely — every action in the list, not just reads, so an
728 + * anonymous caller can't read, write, or delete anything under it. Kept
729 + * as one statement with NotResource rather than split into "reads
730 + * excluded, everything else still public" — that split would still let
731 + * anonymous PutObject/DeleteObject reach a "private" file.
732 + * @since 1.4.1 $private_prefix param added.
567 733 */
568 - private function putBucketPolicy($bucket, $s3Client = false) {
734 + private function putBucketPolicy($bucket, $s3Client = false, $private_prefix = '') {
569 735 if($s3Client == false) {
570 736 $s3Client = $this->s3Client;
571 737 }
572 738
@@ -571,47 +737,79 @@
571 737 }
572 738
573 739 if(empty($bucket)) return false;
574 740
741 + $actions = [
742 + "s3:DeleteObjectTagging",
743 + "s3:ListBucketMultipartUploads",
744 + "s3:DeleteObjectVersion",
745 + "s3:ListBucket",
746 + "s3:DeleteObjectVersionTagging",
747 + "s3:GetBucketAcl",
748 + "s3:ListMultipartUploadParts",
749 + "s3:PutObject",
750 + "s3:GetObjectAcl",
751 + "s3:GetObject",
752 + "s3:AbortMultipartUpload",
753 + "s3:DeleteObject",
754 + "s3:GetBucketLocation",
755 + "s3:PutObjectAcl",
756 + "s3:putBucketOwnershipControls",
757 + "s3:putBucketPolicy"
758 + ];
759 +
760 + $statement = [
761 + "Effect" => "Allow",
762 + "Principal" => "*",
763 + "Action" => $actions,
764 + ];
765 +
766 + if (!empty($private_prefix)) {
767 + $statement["NotResource"] = ["arn:aws:s3:::$bucket/$private_prefix/*"];
768 + } else {
769 + $statement["Resource"] = [
770 + "arn:aws:s3:::$bucket/*",
771 + "arn:aws:s3:::$bucket"
772 + ];
773 + }
774 +
575 775 $policy = json_encode([
576 - "Version" => "2012-10-17",
577 - "Statement" => [
578 - [
579 - "Effect" => "Allow",
580 - "Principal" => "*",
581 - "Action" => [
582 - "s3:DeleteObjectTagging",
583 - "s3:ListBucketMultipartUploads",
584 - "s3:DeleteObjectVersion",
585 - "s3:ListBucket",
586 - "s3:DeleteObjectVersionTagging",
587 - "s3:GetBucketAcl",
588 - "s3:ListMultipartUploadParts",
589 - "s3:PutObject",
590 - "s3:GetObjectAcl",
591 - "s3:GetObject",
592 - "s3:AbortMultipartUpload",
593 - "s3:DeleteObject",
594 - "s3:GetBucketLocation",
595 - "s3:PutObjectAcl",
596 - "s3:putBucketOwnershipControls",
597 - "s3:putBucketPolicy"
598 - ],
599 - "Resource" => [
600 - "arn:aws:s3:::$bucket/*",
601 - "arn:aws:s3:::$bucket"
602 - ]
603 - ]
604 - ]
776 + "Version" => "2012-10-17",
777 + "Statement" => [$statement]
605 778 ]);
606 779
607 - // Add bucket policy
608 - $s3Client->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
780 + try {
781 + // Add bucket policy
782 + $s3Client->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
609 783
610 - return true;
784 + return true;
785 + } catch (AwsException $ex) {
786 + return false;
787 + } catch (S3Exception $ex) {
788 + return false;
789 + } catch (Exception $ex) {
790 + return false;
791 + }
611 792 }
612 793
613 794 /**
795 + * Apply (or, with an empty $private_prefix, un-apply) the private-path
796 + * bucket policy carve-out.
797 + * @since 1.4.1
798 + */
799 + public function applyPrivatePathPolicy($private_prefix) {
800 + if (!$this->s3Client || empty($this->bucket_name)) {
801 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
802 + }
803 +
804 + $ok = $this->putBucketPolicy($this->bucket_name, $this->s3Client, $private_prefix);
805 +
806 + return $ok
807 + ? ['success' => true, 'code' => 200, 'message' => esc_html__('Policy applied successfully', 'media-cloud-sync')]
808 + : ['success' => false, 'code' => 200, 'message' => esc_html__('Failed to apply bucket policy', 'media-cloud-sync')];
809 + }
810 +
811 + /**
614 812 * Add Bucket Ownership
615 813 */
616 814 private function changeBucketOwnership($bucket, $s3Client = false, $ownership = 'BucketOwnerPreferred') {
617 815 if($s3Client == false) {
@@ -619,17 +817,25 @@
619 817 }
620 818
621 819 if(empty($bucket)) return false;
622 820
623 - // Change object ownership ACL enabled
624 - $s3Client->putBucketOwnershipControls([
625 - 'Bucket' => $bucket,
626 - 'OwnershipControls' => [
627 - 'Rules' => [['ObjectOwnership' => $ownership]],
628 - ],
629 - ]);
821 + try {
822 + // Change object ownership ACL enabled
823 + $s3Client->putBucketOwnershipControls([
824 + 'Bucket' => $bucket,
825 + 'OwnershipControls' => [
826 + 'Rules' => [['ObjectOwnership' => $ownership]],
827 + ],
828 + ]);
630 829
631 - return true;
830 + return true;
831 + } catch (AwsException $ex) {
832 + return false;
833 + } catch (S3Exception $ex) {
834 + return false;
835 + } catch (Exception $ex) {
836 + return false;
837 + }
632 838 }
633 839
634 840 /**
635 841 * Change transfer accilaration
@@ -641,16 +847,24 @@
641 847
642 848 if(empty($bucket)) return false;
643 849 if(!$force && !$enable) return true;
644 850
645 - $s3Client->putBucketAccelerateConfiguration([
646 - 'Bucket' => $bucket,
647 - 'AccelerateConfiguration' => [
648 - 'Status' => $enable ? 'Enabled' : 'Suspended'
649 - ]
650 - ]);
651 -
652 - return true;
851 + // Check if the bucket already has transfer acceleration enabled
852 + try {
853 + $s3Client->putBucketAccelerateConfiguration([
854 + 'Bucket' => $bucket,
855 + 'AccelerateConfiguration' => [
856 + 'Status' => $enable ? 'Enabled' : 'Suspended'
857 + ]
858 + ]);
859 + return true;
860 + } catch (AwsException $ex) {
861 + return false;
862 + } catch (S3Exception $ex) {
863 + return false;
864 + } catch (Exception $ex) {
865 + return false;
866 + }
653 867 }
654 868
655 869 /**
656 870 * isConfigured Function To Identify the congfigurations are correct
@@ -658,18 +872,35 @@
658 872 */
659 873 public function isConfigured(){
660 874 if ($this->s3Client) {
661 875 try {
662 - $buckets = $s3Client->listBuckets();
663 - if(!empty($buckets)){
664 - foreach($buckets as $bucket) {
665 - if ($bucket['Name']==$this->bucket_name) {
666 - return true;
667 - }
668 - }
669 - }
876 + $this->s3Client->listObjectsV2([
877 + 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
878 + ]);
879 +
880 + // If we reach here, the credentials are valid
881 + return true;
882 + } catch (AwsException $ex) {
883 + $code = $ex->getAwsErrorCode();
884 +
885 + $validErrors = [
886 + 'AccessDenied',
887 + 'NoSuchBucket',
888 + 'AllAccessDisabled',
889 + 'AuthorizationHeaderMalformed',
890 + 'PermanentRedirect',
891 + 'InvalidBucketName'
892 + ];
893 +
894 + if (in_array($code, $validErrors)) {
895 + // If we reach here, the credentials are valid
896 + return true;
897 + } else {
898 + return false;
899 + }
900 + } catch (S3Exception $ex) {
670 901 return false;
671 - } catch (AwsException $ex) {
902 + } catch (Exception $ex) {
672 903 return false;
673 904 }
674 905 }
675 906 return false;
@@ -681,8 +912,9 @@
681 912 *
682 913 */
683 914 public function toPrivate($key) {
684 915 if(!$key) return false;
916 + if(!$this->s3Client) return false;
685 917 try {
686 918 $this->s3Client->putObjectAcl([
687 919 'Bucket' => $this->bucket_name,
688 920 'Key' => $key,
@@ -691,9 +923,8 @@
691 923 return true;
692 924 } catch (AwsException $ex) {
693 925 return false;
694 926 }
695 - return false;
696 927 }
697 928
698 929
699 930
@@ -699,23 +930,23 @@
699 930
700 931 /**
701 932 * Make Object Public
702 933 * @since 1.0.0
703 - *
934 + *
704 935 */
705 936 public function toPublic($key) {
706 937 if(!$key) return false;
938 + if(!$this->s3Client) return false;
707 939 try {
708 940 $this->s3Client->putObjectAcl([
709 941 'Bucket' => $this->bucket_name,
710 942 'Key' => $key,
711 943 'ACL' => 'public-read'
712 - ]);
944 + ]);
713 945 return true;
714 946 } catch (AwsException $ex) {
715 947 return false;
716 948 }
717 - return false;
718 949 }
719 950
720 951
721 952
@@ -722,16 +953,76 @@
722 953 /**
723 954 * Check the object exist
724 955 * @since 1.1.8
725 956 */
726 - public function exists($key) {
957 + public function exists($key, $bucket_name = '', $client = null) {
727 958 if(!$key) return false;
959 + try {
960 + $bucket_name = $bucket_name ? $bucket_name : $this->bucket_name;
961 + $client = $client ?? $this->s3Client;
962 + if($client->doesObjectExistV2( $bucket_name, $key)) {
963 + return true;
964 + }
965 + return false;
966 + } catch (AwsException $ex) {
967 + return false;
968 + } catch (S3Exception $ex) {
969 + return false;
970 + } catch (Exception $ex) {
971 + return false;
972 + }
973 + }
728 974
729 - if($this->s3Client->doesObjectExist($this->bucket_name, $key)) {
730 - return true;
975 + /**
976 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
977 + * @since 1.3.13
978 + */
979 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
980 + if (!$this->s3Client) {
981 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
731 982 }
732 -
733 - return false;
983 + try {
984 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
985 + if (!empty($delimiter)) {
986 + $params['Delimiter'] = $delimiter;
987 + }
988 + if (!empty($prefix)) {
989 + $params['Prefix'] = $prefix;
990 + }
991 + if (!empty($continuationToken)) {
992 + $params['ContinuationToken'] = $continuationToken;
993 + }
994 +
995 + $result = $this->s3Client->listObjectsV2($params);
996 + $folders = [];
997 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
998 + $folders[] = $common['Prefix'];
999 + }
1000 + $objects = [];
1001 + foreach (($result['Contents'] ?? []) as $object) {
1002 + if ($object['Key'] === $prefix) {
1003 + continue; // the folder placeholder object itself, not a file
1004 + }
1005 + $objects[] = [
1006 + 'key' => $object['Key'],
1007 + 'size' => (int) $object['Size'],
1008 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
1009 + ];
1010 + }
1011 +
1012 + return [
1013 + 'success' => true,
1014 + 'code' => 200,
1015 + 'message' => '',
1016 + 'folders' => $folders,
1017 + 'objects' => $objects,
1018 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
1019 + ];
1020 + } catch (AwsException $e) {
1021 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
1022 + } catch (Exception $e) {
1023 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
1024 + }
734 1025 }
735 1026
736 1027 /**
737 1028 * Upload Single
@@ -737,97 +1028,132 @@
737 1028 * Upload Single
738 1029 * @since 1.0.0
739 1030 * @return boolean
740 1031 */
741 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
742 - $result = array();
1032 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
743 1033 if (
744 - isset($media_absolute_path) && !empty($media_absolute_path) &&
745 - isset($media_path) && !empty($media_path)
1034 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1035 + isset($relative_source_path) && !empty($relative_source_path)
746 1036 ) {
747 - $file_name = wp_basename( $media_path );
1037 + $file_name = wp_basename( $relative_source_path );
748 1038 if ($file_name) {
749 - $upload_path = Utils::generate_object_key($media_path, $prefix);
750 -
751 - // Decide Multipart upload or normal put object
752 - if (filesize($media_absolute_path) <= Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE')) {
753 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
754 - try {
755 - $upload = $this->s3Client->putObject([
756 - 'Bucket' => $this->bucket_name,
757 - 'Key' => $upload_path,
758 - 'Body' => fopen($media_absolute_path, 'r'),
759 - ]);
1039 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
1040 + if ($upload_path === false) {
1041 + // Only happens for a private reupload with no private-path provider
1042 + // available (Pro inactive/unlicensed) — refuse rather than upload
1043 + // an already-private file to an unprotected path.
1044 + return [
1045 + 'success' => false,
1046 + 'code' => 200,
1047 + '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')
1048 + ];
1049 + }
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
1051 + }
1052 + return [
1053 + 'success' => false,
1054 + 'code' => 200,
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
1056 + ];
1057 + }
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1062 + ];
1063 + }
760 1064
761 - $result = array(
762 - 'success' => true,
763 - 'code' => 200,
764 - 'file_url' => $this->generate_file_url($upload_path),
765 - 'key' => $upload_path,
766 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
767 - );
768 - } catch (AwsException $e) {
769 - $result = array(
770 - 'success' => false,
771 - 'code' => 200,
772 - 'message' => $e->getMessage()
773 - );
774 - }
775 - } else {
776 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
777 - 'bucket' => $this->bucket_name,
778 - 'key' => $upload_path,
779 - ]);
780 -
781 - try {
782 - do {
783 - try {
784 - $uploaded = $multiUploader->upload();
785 - } catch (MultipartUploadException $e) {
786 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
787 - 'state' => $e->getState(),
788 - ]);
789 - }
790 - } while (!isset($uploaded));
1065 + /**
1066 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
1067 + * @since 1.4.0
1068 + */
1069 + public function uploadObjectAtKey($absolute_source_path, $key) {
1070 + return $this->execute_upload($absolute_source_path, $key);
1071 + }
791 1072
792 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
793 - $result = array(
794 - 'success' => true,
795 - 'code' => 200,
796 - 'file_url' => $this->generate_file_url($upload_path),
797 - 'key' => $upload_path,
798 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
799 - );
800 - } else {
801 - $result = array(
802 - 'success' => false,
803 - 'code' => 200,
804 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
805 - );
806 - }
807 - } catch (MultipartUploadException $e) {
808 - $result = array(
809 - 'success' => false,
810 - 'code' => 200,
811 - 'message' => $e->getMessage()
812 - );
813 - }
1073 + /**
1074 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
1075 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
1076 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
1077 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
1078 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
1079 + * previously-failed multipart attempt).
1080 + * @since 1.4.0
1081 + */
1082 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
1083 + $handle = fopen($absolute_source_path, 'rb');
1084 + $params = [];
1085 + $cache_control = Utils::get_cache_control_header();
1086 + if ($cache_control) {
1087 + $params['CacheControl'] = $cache_control;
1088 + }
1089 + $options += [
1090 + 'mup_threshold' => Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE'),
1091 + 'params' => $params,
1092 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
1093 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
1094 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
1095 + ];
1096 + return new ObjectUploader($this->s3Client, $this->bucket_name, $key, $handle, null, $options);
1097 + }
1098 +
1099 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
1100 + // return value, relying on the same Command object being modified.
1101 + private function strip_acl($params) {
1102 + if ($params instanceof Command && $params->hasParam('ACL')) {
1103 + unset($params['ACL']);
1104 + } elseif (is_array($params) && isset($params['ACL'])) {
1105 + unset($params['ACL']);
1106 + }
1107 + return $params;
1108 + }
1109 +
1110 + /**
1111 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
1112 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
1113 + * state rather than restarting the whole upload — same retry contract uploadSingle()
1114 + * had before the ObjectUploader swap.
1115 + * @since 1.4.0
1116 + */
1117 + private function execute_upload($absolute_source_path, $key) {
1118 + $max_attempts = 3;
1119 + $attempt = 0;
1120 + $options = [];
1121 +
1122 + while (true) {
1123 + $attempt++;
1124 + try {
1125 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
1126 + return [
1127 + 'success' => true,
1128 + 'code' => 200,
1129 + 'file_url' => $this->generate_file_url($key),
1130 + 'key' => $key,
1131 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
1132 + ];
1133 + } catch (MultipartUploadException $e) {
1134 + if ($attempt >= $max_attempts) {
1135 + return [
1136 + 'success' => false,
1137 + 'code' => 200,
1138 + 'message' => $e->getMessage()
1139 + ];
814 1140 }
815 - } else {
816 - $result = array(
1141 + $options = ['state' => $e->getState()];
1142 + } catch (AwsException $e) {
1143 + return [
817 1144 'success' => false,
818 1145 'code' => 200,
819 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
820 - );
1146 + 'message' => $e->getMessage()
1147 + ];
1148 + } catch (Exception $e) {
1149 + return [
1150 + 'success' => false,
1151 + 'code' => 200,
1152 + 'message' => $e->getMessage()
1153 + ];
821 1154 }
822 - } else {
823 - $result = array(
824 - 'success' => false,
825 - 'code' => 200,
826 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
827 - );
828 1155 }
829 - return $result;
830 1156 }
831 1157
832 1158 /**
833 1159 * Save object to server
@@ -833,8 +1159,9 @@
833 1159 * Save object to server
834 1160 * @since 1.0.0
835 1161 */
836 1162 public function object_to_server($key, $save_path) {
1163 + if(!$this->s3Client) return false;
837 1164 try {
838 1165 $getObject = $this->s3Client->GetObject([
839 1166 'Bucket' => $this->bucket_name,
840 1167 'Key' => $key,
@@ -848,10 +1175,156 @@
848 1175 }
849 1176 return false;
850 1177 }
851 1178
1179 + /**
1180 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
1181 + * the content itself rather than a copy on the server's filesystem.
1182 + * @since 1.3.13
1183 + */
1184 + public function get_object_content($key) {
1185 + if(!$this->s3Client) return false;
1186 + try {
1187 + $result = $this->s3Client->GetObject([
1188 + 'Bucket' => $this->bucket_name,
1189 + 'Key' => $key,
1190 + ]);
1191 + return (string) $result['Body'];
1192 + } catch (AwsException $e) {
1193 + return false;
1194 + }
1195 + }
852 1196
853 1197 /**
1198 + * Deletes the live object, then best-effort purges every historical version too — a
1199 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
1200 + * versions (and the storage they use) behind at the old key. The live delete happens
1201 + * unconditionally first: not every S3-compatible endpoint supports ListObjectVersions
1202 + * (confirmed missing on Cloudflare R2, a live 501 "NotImplemented"), and the object must
1203 + * still end up gone either way.
1204 + * @since 1.3.14
1205 + */
1206 + public function purge_all_versions($key) {
1207 + if (!$this->s3Client) {
1208 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
1209 + }
1210 +
1211 + try {
1212 + $this->s3Client->deleteObject([
1213 + 'Bucket' => $this->bucket_name,
1214 + 'Key' => $key,
1215 + ]);
1216 + } catch (AwsException $e) {
1217 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1218 + }
1219 +
1220 + // Best-effort only from here — providers that don't support version listing simply
1221 + // skip this part; the live object above is already gone regardless.
1222 + try {
1223 + $objects = [];
1224 + $marker = null;
1225 + do {
1226 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
1227 + if ($marker) {
1228 + $args['KeyMarker'] = $marker['key'];
1229 + $args['VersionIdMarker'] = $marker['version'];
1230 + }
1231 + $result = $this->s3Client->listObjectVersions($args);
1232 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
1233 + if (($version['Key'] ?? null) === $key) {
1234 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
1235 + }
1236 + }
1237 + $marker = !empty($result['IsTruncated'])
1238 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
1239 + : null;
1240 + } while ($marker);
1241 +
1242 + foreach (array_chunk($objects, 1000) as $chunk) {
1243 + $this->s3Client->deleteObjects([
1244 + 'Bucket' => $this->bucket_name,
1245 + 'Delete' => ['Objects' => $chunk],
1246 + ]);
1247 + }
1248 + } catch (AwsException $e) {
1249 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
1250 + }
1251 +
1252 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
1253 + }
1254 +
1255 +
1256 + /**
1257 + * Copy object to new path
1258 + * @since 1.3.4
1259 + */
1260 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
1261 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
1262 + // processing keys sequentially, three extra round-trips per file adds up fast on a
1263 + // folder with many files. copyObject() itself throws (caught below) if the source is
1264 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
1265 + public function copy_to_new_path($key, $new_path) {
1266 + if (!$this->s3Client) {
1267 + return [
1268 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1269 + 'code' => 200,
1270 + 'success' => false
1271 + ];
1272 + }
1273 + try {
1274 + $this->s3Client->copyObject([
1275 + 'Bucket' => $this->bucket_name,
1276 + 'CopySource' => "{$this->bucket_name}/{$key}",
1277 + 'Key' => $new_path,
1278 + 'MetadataDirective' => 'COPY',
1279 + ]);
1280 + return [
1281 + 'success' => true,
1282 + 'code' => 200,
1283 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1284 + ];
1285 + } catch (AwsException $e) {
1286 + return [
1287 + 'success' => false,
1288 + 'code' => 200,
1289 + 'message' => $e->getMessage()
1290 + ];
1291 + }
1292 + }
1293 +
1294 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
1295 + // access there too, so callers should fall back to download+upload on failure.
1296 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
1297 + if (!$this->s3Client) {
1298 + return [
1299 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1300 + 'code' => 200,
1301 + 'success' => false
1302 + ];
1303 + }
1304 + try {
1305 + $this->s3Client->copyObject([
1306 + 'Bucket' => $dest_bucket,
1307 + 'CopySource' => "{$this->bucket_name}/{$key}",
1308 + 'Key' => $new_key,
1309 + 'MetadataDirective' => 'COPY',
1310 + ]);
1311 + return [
1312 + 'success' => true,
1313 + 'code' => 200,
1314 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1315 + ];
1316 + } catch (AwsException $e) {
1317 + return [
1318 + 'success' => false,
1319 + 'code' => 200,
1320 + 'message' => $e->getMessage()
1321 + ];
1322 + }
1323 + }
1324 +
1325 +
1326 + /**
854 1327 * Delete Single
855 1328 * @since 1.0.0
856 1329 * @return boolean
857 1330 */
@@ -856,8 +1329,15 @@
856 1329 * @return boolean
857 1330 */
858 1331 public function deleteSingle($key) {
859 1332 $result = array();
1333 + if (!$this->s3Client) {
1334 + return array(
1335 + 'success' => false,
1336 + 'code' => 200,
1337 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1338 + );
1339 + }
860 1340 if (isset($key) && !empty($key)) {
861 1341 try {
862 1342 $this->s3Client->deleteObject([
863 1343 'Bucket' => $this->bucket_name,
@@ -863,9 +1343,9 @@
863 1343 'Bucket' => $this->bucket_name,
864 1344 'Key' => $key
865 1345 ]);
866 1346
867 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $key)) {
1347 + if (!$this->exists($key)) {
868 1348 $result = array(
869 1349 'success' => true,
870 1350 'code' => 200,
871 1351 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -894,14 +1374,21 @@
894 1374 return $result;
895 1375 }
896 1376
897 1377 /**
898 - * get presigned URL
1378 + * get private URL
899 1379 * @since 1.0.0
900 1380 * @return boolean
901 1381 */
902 - public function get_presigned_url($key) {
1382 + public function get_private_url($key) {
903 1383 $result = array();
1384 + if (!$this->s3Client) {
1385 + return array(
1386 + 'success' => false,
1387 + 'code' => 200,
1388 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1389 + );
1390 + }
904 1391 if (isset($key) && !empty($key)) {
905 1392 try {
906 1393 $cmd = $this->s3Client->getCommand('GetObject', [
907 1394 'Bucket' => $this->bucket_name,
@@ -907,24 +1394,24 @@
907 1394 'Bucket' => $this->bucket_name,
908 1395 'Key' => $key
909 1396 ]);
910 1397
911 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1398 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
912 1399
913 1400 $request = $this->s3Client->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
914 1401
915 - if ($presignedUrl = (string)$request->getUri()) {
1402 + if ($privateUrl = (string)$request->getUri()) {
916 1403 $result = array(
917 1404 'success' => true,
918 1405 'code' => 200,
919 - 'file_url' => $presignedUrl,
920 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1406 + 'file_url' => $privateUrl,
1407 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
921 1408 );
922 1409 } else {
923 1410 $result = array(
924 1411 'success' => false,
925 1412 'code' => 200,
926 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1413 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
927 1414 );
928 1415 }
929 1416 } catch (AwsException $e) {
930 1417 $result = array(
@@ -945,9 +1432,9 @@
945 1432
946 1433 /**
947 1434 * Generate file URL
948 1435 */
949 - private function generate_file_url($key){
1436 + public function generate_file_url($key){
950 1437 $domain = $this->get_domain();
951 1438
952 1439 return apply_filters('wpmcs_generate_s3_file_url',
953 1440 $domain . '/' . $key,
@@ -952,8 +1439,17 @@
952 1439 return apply_filters('wpmcs_generate_s3_file_url',
953 1440 $domain . '/' . $key,
954 1441 $domain, $key
955 1442 );
1443 + }
1444 +
1445 + /**
1446 + * Is Provider URL
1447 + * @since 1.3.6
1448 + */
1449 + public function is_provider_url($url) {
1450 + $domain = $this->get_domain();
1451 + return (strpos($url, $domain . '/') !== false);
956 1452 }
957 1453
958 1454 /**
959 1455 * Get domain URL