PluginProbe
Media Cloud Sync / trunk
Media Cloud Sync vtrunk
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 1.3.0 All 34 releases
← All changes | includes/base/services/s3.php +451 -148 1.2.12trunk 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;
@@ -29,23 +31,26 @@
29 31 /**
30 32 * Admin constructor.
31 33 * @since 1.0.0
32 34 */
33 - public function __construct() {
35 + public function __construct($credentials = null) {
34 36 $this->assets_url = WPMCS_ASSETS_URL;
35 37 $this->version = WPMCS_VERSION;
36 38 $this->token = WPMCS_TOKEN;
37 39
38 40 // Initialize setup
39 - $this->init();
41 + $this->init($credentials);
40 42 }
41 43
42 44 /**
43 45 * Initialise Client
46 + *
47 + * @param array|null $credentials Optional explicit credentials; falls back to
48 + * Utils::get_credentials() when omitted.
44 49 */
45 - public function init() {
50 + public function init($credentials = null) {
46 51 $this->settings = Utils::get_settings();
47 - $this->credentials = Utils::get_credentials();
52 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
48 53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
49 54 ? $this->credentials['config']
50 55 : [];
51 56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -83,14 +88,14 @@
83 88 * Verify Credentials
84 89 * @since 1.0.0
85 90 * @return boolean
86 91 */
87 - public function verifyCredentials($access_key, $secret_key, $region){
88 - if (
89 - isset($region) && !empty($region) &&
90 - isset($access_key) && !empty($access_key) &&
91 - isset($secret_key) && !empty($secret_key)
92 - ) {
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])) {
93 98 try {
94 99 $s3Client = new S3Client([
95 100 'version' => '2006-03-01',
96 101 'region' => $region,
@@ -181,15 +186,16 @@
181 186 * Verify Bucket Exist
182 187 * @since 1.0.0
183 188 * @return boolean
184 189 */
185 - public function verifyBucketExist($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration=false){
186 - if (
187 - isset($region) && !empty($region) &&
188 - isset($access_key) && !empty($access_key) &&
189 - isset($secret_key) && !empty($secret_key) &&
190 - isset($bucket_name) && !empty($bucket_name)
191 - ) {
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])) {
192 198 try {
193 199 $s3Client = new S3Client([
194 200 'version' => '2006-03-01',
195 201 'region' => $region,
@@ -235,10 +241,16 @@
235 241 * Create Bucket
236 242 * @since 1.0.0
237 243 * @return boolean
238 244 */
239 - public function createBucket($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
240 - 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])) {
241 253 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
242 254 }
243 255
244 256 try {
@@ -303,10 +315,16 @@
303 315 /**
304 316 * Check Bucket Write Permission
305 317 * @since 1.0.0
306 318 */
307 - public function verifyObjectWritePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
308 - 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])) {
309 327 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
310 328 }
311 329
312 330 try {
@@ -322,9 +340,9 @@
322 340 ];
323 341
324 342 $s3Client = new S3Client($s3ClientConfig);
325 343
326 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
344 + $object_key = Utils::get_permission_check_object_key();
327 345
328 346
329 347 // Create a dummy object to check write permission
330 348 $s3Client->putObject([
@@ -332,9 +350,9 @@
332 350 'Key' => $object_key,
333 351 'Body' => 'This is a test object to check write permission.',
334 352 ]);
335 353 // Check if the object was created successfully
336 - if ($s3Client->doesObjectExist($bucket_name, $object_key)) {
354 + if ($this->exists($object_key, $bucket_name, $s3Client)) {
337 355 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
338 356 } else {
339 357 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
340 358 }
@@ -353,10 +371,16 @@
353 371 /**
354 372 * Check Bucket Delete Permission
355 373 * @since 1.0.0
356 374 */
357 - public function verifyObjectDeletePermission($access_key, $secret_key, $region, $bucket_name, $transfer_acceleration = false){
358 - 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])) {
359 383 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
360 384 }
361 385
362 386 try {
@@ -372,9 +396,9 @@
372 396 ];
373 397
374 398 $s3Client = new S3Client($s3ClientConfig);
375 399
376 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
400 + $object_key = Utils::get_permission_check_object_key();
377 401
378 402 // Create a dummy object to check dlete permission
379 403 $s3Client->deleteObject([
380 404 'Bucket' => $bucket_name,
@@ -381,9 +405,9 @@
381 405 'Key' => $object_key,
382 406 ]);
383 407
384 408 // Check if the object was created successfully
385 - if (!$s3Client->doesObjectExist($bucket_name, $object_key)) {
409 + if (!$this->exists($object_key, $bucket_name, $s3Client)) {
386 410 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
387 411 } else {
388 412 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
389 413 }
@@ -402,49 +426,59 @@
402 426 * Check Bucket Read Permission
403 427 * @since 1.2.4
404 428 */
405 429 public function verifyObjectReadPermission() {
406 - if (empty($this->s3Client) || empty($this->bucket_name)) {
407 - return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false];
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()];
408 439 }
409 440
410 441 try {
411 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
442 + $object_key = Utils::get_permission_check_object_key();
412 443
413 444 // Check if the object was created successfully
414 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $object_key)) {
445 + if (!$this->exists($object_key)) {
415 446 // Create a dummy object to check write permission
416 447 $this->s3Client->putObject([
417 448 'Bucket' => $this->bucket_name,
418 449 'Key' => $object_key,
419 450 'Body' => 'This is a test object to check permission.',
451 + 'ContentType' => 'text/plain',
452 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
420 453 ]);
421 - }
422 -
454 + }
423 455
456 +
424 457 $url = $this->generate_file_url($object_key);
425 458 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
426 - $headers = @get_headers($cdn_url);
427 - $result = [
428 - 'status' => false,
429 - 'message' => '',
430 - 'lastChecked' => time(),
431 - ];
432 - if (strpos($headers[0], '200') !== false) {
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) {
433 468 $result['status'] = true;
434 469 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
435 - } else if (strpos($headers[0], '403') !== false) {
470 + } else if ($status_code === 403) {
436 471 $result['status'] = false;
437 - if($this->cdnConfig['service'] == $this->service) {
472 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
438 473 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
439 474 } else {
440 475 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
441 476 }
442 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
443 - } else if (strpos($headers[0], '404') !== false) {
477 + } else if ($status_code === 404) {
444 478 $result['status'] = false;
445 479 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
446 - } else if (strpos($headers[0], '500') !== false) {
480 + } else if ($status_code === 500) {
447 481 $result['status'] = false;
448 482 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
449 483 } else {
450 484 $result['status'] = false;
@@ -449,9 +483,8 @@
449 483 } else {
450 484 $result['status'] = false;
451 485 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
452 486 }
453 - Utils::set_status('cdnRead', $result);
454 487
455 488 $this->deleteSingle($object_key);
456 489 return [
457 490 'message' => $result['message'],
@@ -459,13 +492,16 @@
459 492 'success' => $result['status'],
460 493 'lastChecked' => $result['lastChecked'],
461 494 ];
462 495 } catch (AwsException $ex) {
463 - return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
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()];
464 498 } catch (S3Exception $ex) {
465 - return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
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()];
466 501 } catch (Exception $ex) {
467 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
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()];
468 504 }
469 505 }
470 506
471 507
@@ -471,9 +507,15 @@
471 507
472 508 /**
473 509 * get Bucket Security Settings
474 510 */
475 - 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 +
476 518 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
477 519 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
478 520 }
479 521
@@ -499,9 +541,8 @@
499 541 'Bucket' => $bucket_name,
500 542 ]);
501 543
502 544 $publicAccessBlockConfig = $publicAccessBlock['PublicAccessBlockConfiguration'];
503 - $security = [];
504 545 if (
505 546 $publicAccessBlockConfig['BlockPublicAcls'] &&
506 547 $publicAccessBlockConfig['IgnorePublicAcls'] &&
507 548 $publicAccessBlockConfig['BlockPublicPolicy'] &&
@@ -549,9 +590,15 @@
549 590 /**
550 591 * Change Bucket Public Access
551 592 */
552 593
553 - 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 +
554 601 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
555 602 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
556 603 }
557 604
@@ -593,9 +640,15 @@
593 640 /**
594 641 * Change Bucket Ownership
595 642 */
596 643
597 - 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 +
598 651 if (empty($region) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
599 652 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
600 653 }
601 654
@@ -794,9 +847,9 @@
794 847
795 848 // If we reach here, the credentials are valid
796 849 return true;
797 850 } catch (AwsException $ex) {
798 - $code = $e->getAwsErrorCode();
851 + $code = $ex->getAwsErrorCode();
799 852
800 853 $validErrors = [
801 854 'AccessDenied',
802 855 'NoSuchBucket',
@@ -827,8 +880,9 @@
827 880 *
828 881 */
829 882 public function toPrivate($key) {
830 883 if(!$key) return false;
884 + if(!$this->s3Client) return false;
831 885 try {
832 886 $this->s3Client->putObjectAcl([
833 887 'Bucket' => $this->bucket_name,
834 888 'Key' => $key,
@@ -837,9 +891,8 @@
837 891 return true;
838 892 } catch (AwsException $ex) {
839 893 return false;
840 894 }
841 - return false;
842 895 }
843 896
844 897
845 898
@@ -845,23 +898,23 @@
845 898
846 899 /**
847 900 * Make Object Public
848 901 * @since 1.0.0
849 - *
902 + *
850 903 */
851 904 public function toPublic($key) {
852 905 if(!$key) return false;
906 + if(!$this->s3Client) return false;
853 907 try {
854 908 $this->s3Client->putObjectAcl([
855 909 'Bucket' => $this->bucket_name,
856 910 'Key' => $key,
857 911 'ACL' => 'public-read'
858 - ]);
912 + ]);
859 913 return true;
860 914 } catch (AwsException $ex) {
861 915 return false;
862 916 }
863 - return false;
864 917 }
865 918
866 919
867 920
@@ -868,14 +921,17 @@
868 921 /**
869 922 * Check the object exist
870 923 * @since 1.1.8
871 924 */
872 - public function exists($key) {
925 + public function exists($key, $bucket_name = '', $client = null) {
873 926 if(!$key) return false;
874 927 try {
875 - if($this->s3Client->doesObjectExist($this->bucket_name, $key)) {
928 + $bucket_name = $bucket_name ? $bucket_name : $this->bucket_name;
929 + $client = $client ?? $this->s3Client;
930 + if($client->doesObjectExistV2( $bucket_name, $key)) {
876 931 return true;
877 932 }
933 + return false;
878 934 } catch (AwsException $ex) {
879 935 return false;
880 936 } catch (S3Exception $ex) {
881 937 return false;
@@ -884,101 +940,178 @@
884 940 }
885 941 }
886 942
887 943 /**
944 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
945 + * @since 1.3.13
946 + */
947 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
948 + if (!$this->s3Client) {
949 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
950 + }
951 + try {
952 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
953 + if (!empty($delimiter)) {
954 + $params['Delimiter'] = $delimiter;
955 + }
956 + if (!empty($prefix)) {
957 + $params['Prefix'] = $prefix;
958 + }
959 + if (!empty($continuationToken)) {
960 + $params['ContinuationToken'] = $continuationToken;
961 + }
962 +
963 + $result = $this->s3Client->listObjectsV2($params);
964 + $folders = [];
965 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
966 + $folders[] = $common['Prefix'];
967 + }
968 + $objects = [];
969 + foreach (($result['Contents'] ?? []) as $object) {
970 + if ($object['Key'] === $prefix) {
971 + continue; // the folder placeholder object itself, not a file
972 + }
973 + $objects[] = [
974 + 'key' => $object['Key'],
975 + 'size' => (int) $object['Size'],
976 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
977 + ];
978 + }
979 +
980 + return [
981 + 'success' => true,
982 + 'code' => 200,
983 + 'message' => '',
984 + 'folders' => $folders,
985 + 'objects' => $objects,
986 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
987 + ];
988 + } catch (AwsException $e) {
989 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
990 + } catch (Exception $e) {
991 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
992 + }
993 + }
994 +
995 + /**
888 996 * Upload Single
889 997 * @since 1.0.0
890 998 * @return boolean
891 999 */
892 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
893 - $result = array();
1000 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='') {
894 1001 if (
895 - isset($media_absolute_path) && !empty($media_absolute_path) &&
896 - isset($media_path) && !empty($media_path)
1002 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1003 + isset($relative_source_path) && !empty($relative_source_path)
897 1004 ) {
898 - $file_name = wp_basename( $media_path );
1005 + $file_name = wp_basename( $relative_source_path );
899 1006 if ($file_name) {
900 - $upload_path = Utils::generate_object_key($media_path, $prefix);
901 -
902 - // Decide Multipart upload or normal put object
903 - if (filesize($media_absolute_path) <= Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE')) {
904 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
905 - try {
906 - $upload = $this->s3Client->putObject([
907 - 'Bucket' => $this->bucket_name,
908 - 'Key' => $upload_path,
909 - 'Body' => fopen($media_absolute_path, 'r'),
910 - ]);
1007 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
1008 + return $this->execute_upload($absolute_source_path, $upload_path);
1009 + }
1010 + return [
1011 + 'success' => false,
1012 + 'code' => 200,
1013 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
1014 + ];
1015 + }
1016 + return [
1017 + 'success' => false,
1018 + 'code' => 200,
1019 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1020 + ];
1021 + }
911 1022
912 - $result = array(
913 - 'success' => true,
914 - 'code' => 200,
915 - 'file_url' => $this->generate_file_url($upload_path),
916 - 'key' => $upload_path,
917 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
918 - );
919 - } catch (AwsException $e) {
920 - $result = array(
921 - 'success' => false,
922 - 'code' => 200,
923 - 'message' => $e->getMessage()
924 - );
925 - }
926 - } else {
927 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
928 - 'bucket' => $this->bucket_name,
929 - 'key' => $upload_path,
930 - ]);
931 -
932 - try {
933 - do {
934 - try {
935 - $uploaded = $multiUploader->upload();
936 - } catch (MultipartUploadException $e) {
937 - $multiUploader = new MultipartUploader($this->s3Client, $media_absolute_path, [
938 - 'state' => $e->getState(),
939 - ]);
940 - }
941 - } while (!isset($uploaded));
1023 + /**
1024 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
1025 + * @since 1.4.0
1026 + */
1027 + public function uploadObjectAtKey($absolute_source_path, $key) {
1028 + return $this->execute_upload($absolute_source_path, $key);
1029 + }
942 1030
943 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
944 - $result = array(
945 - 'success' => true,
946 - 'code' => 200,
947 - 'file_url' => $this->generate_file_url($upload_path),
948 - 'key' => $upload_path,
949 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
950 - );
951 - } else {
952 - $result = array(
953 - 'success' => false,
954 - 'code' => 200,
955 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
956 - );
957 - }
958 - } catch (MultipartUploadException $e) {
959 - $result = array(
960 - 'success' => false,
961 - 'code' => 200,
962 - 'message' => $e->getMessage()
963 - );
964 - }
1031 + /**
1032 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
1033 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
1034 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
1035 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
1036 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
1037 + * previously-failed multipart attempt).
1038 + * @since 1.4.0
1039 + */
1040 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
1041 + $handle = fopen($absolute_source_path, 'rb');
1042 + $params = [];
1043 + $cache_control = Utils::get_cache_control_header();
1044 + if ($cache_control) {
1045 + $params['CacheControl'] = $cache_control;
1046 + }
1047 + $options += [
1048 + 'mup_threshold' => Schema::getConstant('S3_MULTIPART_MIN_FILE_SIZE'),
1049 + 'params' => $params,
1050 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
1051 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
1052 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
1053 + ];
1054 + return new ObjectUploader($this->s3Client, $this->bucket_name, $key, $handle, null, $options);
1055 + }
1056 +
1057 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
1058 + // return value, relying on the same Command object being modified.
1059 + private function strip_acl($params) {
1060 + if ($params instanceof Command && $params->hasParam('ACL')) {
1061 + unset($params['ACL']);
1062 + } elseif (is_array($params) && isset($params['ACL'])) {
1063 + unset($params['ACL']);
1064 + }
1065 + return $params;
1066 + }
1067 +
1068 + /**
1069 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
1070 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
1071 + * state rather than restarting the whole upload — same retry contract uploadSingle()
1072 + * had before the ObjectUploader swap.
1073 + * @since 1.4.0
1074 + */
1075 + private function execute_upload($absolute_source_path, $key) {
1076 + $max_attempts = 3;
1077 + $attempt = 0;
1078 + $options = [];
1079 +
1080 + while (true) {
1081 + $attempt++;
1082 + try {
1083 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
1084 + return [
1085 + 'success' => true,
1086 + 'code' => 200,
1087 + 'file_url' => $this->generate_file_url($key),
1088 + 'key' => $key,
1089 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
1090 + ];
1091 + } catch (MultipartUploadException $e) {
1092 + if ($attempt >= $max_attempts) {
1093 + return [
1094 + 'success' => false,
1095 + 'code' => 200,
1096 + 'message' => $e->getMessage()
1097 + ];
965 1098 }
966 - } else {
967 - $result = array(
1099 + $options = ['state' => $e->getState()];
1100 + } catch (AwsException $e) {
1101 + return [
968 1102 'success' => false,
969 1103 'code' => 200,
970 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
971 - );
1104 + 'message' => $e->getMessage()
1105 + ];
1106 + } catch (Exception $e) {
1107 + return [
1108 + 'success' => false,
1109 + 'code' => 200,
1110 + 'message' => $e->getMessage()
1111 + ];
972 1112 }
973 - } else {
974 - $result = array(
975 - 'success' => false,
976 - 'code' => 200,
977 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
978 - );
979 1113 }
980 - return $result;
981 1114 }
982 1115
983 1116 /**
984 1117 * Save object to server
@@ -984,8 +1117,9 @@
984 1117 * Save object to server
985 1118 * @since 1.0.0
986 1119 */
987 1120 public function object_to_server($key, $save_path) {
1121 + if(!$this->s3Client) return false;
988 1122 try {
989 1123 $getObject = $this->s3Client->GetObject([
990 1124 'Bucket' => $this->bucket_name,
991 1125 'Key' => $key,
@@ -999,10 +1133,156 @@
999 1133 }
1000 1134 return false;
1001 1135 }
1002 1136
1137 + /**
1138 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
1139 + * the content itself rather than a copy on the server's filesystem.
1140 + * @since 1.3.13
1141 + */
1142 + public function get_object_content($key) {
1143 + if(!$this->s3Client) return false;
1144 + try {
1145 + $result = $this->s3Client->GetObject([
1146 + 'Bucket' => $this->bucket_name,
1147 + 'Key' => $key,
1148 + ]);
1149 + return (string) $result['Body'];
1150 + } catch (AwsException $e) {
1151 + return false;
1152 + }
1153 + }
1003 1154
1004 1155 /**
1156 + * Deletes the live object, then best-effort purges every historical version too — a
1157 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
1158 + * versions (and the storage they use) behind at the old key. The live delete happens
1159 + * unconditionally first: not every S3-compatible endpoint supports ListObjectVersions
1160 + * (confirmed missing on Cloudflare R2, a live 501 "NotImplemented"), and the object must
1161 + * still end up gone either way.
1162 + * @since 1.3.14
1163 + */
1164 + public function purge_all_versions($key) {
1165 + if (!$this->s3Client) {
1166 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
1167 + }
1168 +
1169 + try {
1170 + $this->s3Client->deleteObject([
1171 + 'Bucket' => $this->bucket_name,
1172 + 'Key' => $key,
1173 + ]);
1174 + } catch (AwsException $e) {
1175 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1176 + }
1177 +
1178 + // Best-effort only from here — providers that don't support version listing simply
1179 + // skip this part; the live object above is already gone regardless.
1180 + try {
1181 + $objects = [];
1182 + $marker = null;
1183 + do {
1184 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
1185 + if ($marker) {
1186 + $args['KeyMarker'] = $marker['key'];
1187 + $args['VersionIdMarker'] = $marker['version'];
1188 + }
1189 + $result = $this->s3Client->listObjectVersions($args);
1190 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
1191 + if (($version['Key'] ?? null) === $key) {
1192 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
1193 + }
1194 + }
1195 + $marker = !empty($result['IsTruncated'])
1196 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
1197 + : null;
1198 + } while ($marker);
1199 +
1200 + foreach (array_chunk($objects, 1000) as $chunk) {
1201 + $this->s3Client->deleteObjects([
1202 + 'Bucket' => $this->bucket_name,
1203 + 'Delete' => ['Objects' => $chunk],
1204 + ]);
1205 + }
1206 + } catch (AwsException $e) {
1207 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
1208 + }
1209 +
1210 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
1211 + }
1212 +
1213 +
1214 + /**
1215 + * Copy object to new path
1216 + * @since 1.3.4
1217 + */
1218 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
1219 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
1220 + // processing keys sequentially, three extra round-trips per file adds up fast on a
1221 + // folder with many files. copyObject() itself throws (caught below) if the source is
1222 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
1223 + public function copy_to_new_path($key, $new_path) {
1224 + if (!$this->s3Client) {
1225 + return [
1226 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1227 + 'code' => 200,
1228 + 'success' => false
1229 + ];
1230 + }
1231 + try {
1232 + $this->s3Client->copyObject([
1233 + 'Bucket' => $this->bucket_name,
1234 + 'CopySource' => "{$this->bucket_name}/{$key}",
1235 + 'Key' => $new_path,
1236 + 'MetadataDirective' => 'COPY',
1237 + ]);
1238 + return [
1239 + 'success' => true,
1240 + 'code' => 200,
1241 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1242 + ];
1243 + } catch (AwsException $e) {
1244 + return [
1245 + 'success' => false,
1246 + 'code' => 200,
1247 + 'message' => $e->getMessage()
1248 + ];
1249 + }
1250 + }
1251 +
1252 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
1253 + // access there too, so callers should fall back to download+upload on failure.
1254 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
1255 + if (!$this->s3Client) {
1256 + return [
1257 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1258 + 'code' => 200,
1259 + 'success' => false
1260 + ];
1261 + }
1262 + try {
1263 + $this->s3Client->copyObject([
1264 + 'Bucket' => $dest_bucket,
1265 + 'CopySource' => "{$this->bucket_name}/{$key}",
1266 + 'Key' => $new_key,
1267 + 'MetadataDirective' => 'COPY',
1268 + ]);
1269 + return [
1270 + 'success' => true,
1271 + 'code' => 200,
1272 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1273 + ];
1274 + } catch (AwsException $e) {
1275 + return [
1276 + 'success' => false,
1277 + 'code' => 200,
1278 + 'message' => $e->getMessage()
1279 + ];
1280 + }
1281 + }
1282 +
1283 +
1284 + /**
1005 1285 * Delete Single
1006 1286 * @since 1.0.0
1007 1287 * @return boolean
1008 1288 */
@@ -1007,8 +1287,15 @@
1007 1287 * @return boolean
1008 1288 */
1009 1289 public function deleteSingle($key) {
1010 1290 $result = array();
1291 + if (!$this->s3Client) {
1292 + return array(
1293 + 'success' => false,
1294 + 'code' => 200,
1295 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1296 + );
1297 + }
1011 1298 if (isset($key) && !empty($key)) {
1012 1299 try {
1013 1300 $this->s3Client->deleteObject([
1014 1301 'Bucket' => $this->bucket_name,
@@ -1014,9 +1301,9 @@
1014 1301 'Bucket' => $this->bucket_name,
1015 1302 'Key' => $key
1016 1303 ]);
1017 1304
1018 - if (!$this->s3Client->doesObjectExist($this->bucket_name, $key)) {
1305 + if (!$this->exists($key)) {
1019 1306 $result = array(
1020 1307 'success' => true,
1021 1308 'code' => 200,
1022 1309 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -1045,14 +1332,21 @@
1045 1332 return $result;
1046 1333 }
1047 1334
1048 1335 /**
1049 - * get presigned URL
1336 + * get private URL
1050 1337 * @since 1.0.0
1051 1338 * @return boolean
1052 1339 */
1053 - public function get_presigned_url($key) {
1340 + public function get_private_url($key) {
1054 1341 $result = array();
1342 + if (!$this->s3Client) {
1343 + return array(
1344 + 'success' => false,
1345 + 'code' => 200,
1346 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1347 + );
1348 + }
1055 1349 if (isset($key) && !empty($key)) {
1056 1350 try {
1057 1351 $cmd = $this->s3Client->getCommand('GetObject', [
1058 1352 'Bucket' => $this->bucket_name,
@@ -1058,24 +1352,24 @@
1058 1352 'Bucket' => $this->bucket_name,
1059 1353 'Key' => $key
1060 1354 ]);
1061 1355
1062 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1356 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
1063 1357
1064 1358 $request = $this->s3Client->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1065 1359
1066 - if ($presignedUrl = (string)$request->getUri()) {
1360 + if ($privateUrl = (string)$request->getUri()) {
1067 1361 $result = array(
1068 1362 'success' => true,
1069 1363 'code' => 200,
1070 - 'file_url' => $presignedUrl,
1071 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1364 + 'file_url' => $privateUrl,
1365 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
1072 1366 );
1073 1367 } else {
1074 1368 $result = array(
1075 1369 'success' => false,
1076 1370 'code' => 200,
1077 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1371 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
1078 1372 );
1079 1373 }
1080 1374 } catch (AwsException $e) {
1081 1375 $result = array(
@@ -1096,9 +1390,9 @@
1096 1390
1097 1391 /**
1098 1392 * Generate file URL
1099 1393 */
1100 - private function generate_file_url($key){
1394 + public function generate_file_url($key){
1101 1395 $domain = $this->get_domain();
1102 1396
1103 1397 return apply_filters('wpmcs_generate_s3_file_url',
1104 1398 $domain . '/' . $key,
@@ -1103,8 +1397,17 @@
1103 1397 return apply_filters('wpmcs_generate_s3_file_url',
1104 1398 $domain . '/' . $key,
1105 1399 $domain, $key
1106 1400 );
1401 + }
1402 +
1403 + /**
1404 + * Is Provider URL
1405 + * @since 1.3.6
1406 + */
1407 + public function is_provider_url($url) {
1408 + $domain = $this->get_domain();
1409 + return (strpos($url, $domain . '/') !== false);
1107 1410 }
1108 1411
1109 1412 /**
1110 1413 * Get domain URL