PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
← All changes | includes/base/services/s3compatible.php +454 -225 1.2.6 → 1.4.2 View file →
@@ -8,8 +8,10 @@
8 8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 +use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 +use Dudlewebs\WPMCS\s3\Aws\Command;
12 14 use Exception;
13 15
14 16 class S3Compatible {
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'])
@@ -58,9 +63,8 @@
58 63 ? $this->credentials['cdn']
59 64 : [];
60 65
61 66 if (
62 - isset($this->config['region']) && !empty($this->config['region']) &&
63 67 isset($this->config['access_key']) && !empty($this->config['access_key']) &&
64 68 isset($this->config['secret_key']) && !empty($this->config['secret_key']) &&
65 69 isset($this->config['endpoint']) && !empty($this->config['endpoint'])
66 70 ) {
@@ -65,9 +69,9 @@
65 69 isset($this->config['endpoint']) && !empty($this->config['endpoint'])
66 70 ) {
67 71 $this->S3CompatibleClient = new S3Client([
68 72 'version' => '2006-03-01',
69 - 'region' => $this->config['region'],
73 + 'region' => $this->config['region'] ? $this->config['region'] : 'us-east-1',
70 74 'endpoint' => $this->config['endpoint'], // DigitalOcean Spaces requires a custom endpoint
71 75 'use_accelerate_endpoint' => isset($this->bucketConfig['transfer_acceleration'])
72 76 ? $this->bucketConfig['transfer_acceleration'] : false,
73 77 'use_path_style_endpoint' => true, // DigitalOcean Spaces often requires path-style endpoints
@@ -86,15 +90,14 @@
86 90 * Verify Credentials
87 91 * @since 1.0.0
88 92 * @return boolean
89 93 */
90 - public function verifyCredentials($endpoint, $access_key, $secret_key, $region){
91 - if (
92 - isset($region) && !empty($region) &&
93 - isset($access_key) && !empty($access_key) &&
94 - isset($secret_key) && !empty($secret_key) &&
95 - isset($endpoint) && !empty($endpoint)
96 - ) {
94 + public function verifyCredentials( $config = [] ){
95 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
96 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
97 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
98 + $region = isset($config['region']) ? $config['region'] : 'us-east-1';
99 + if (!Service::has_missing_fields([$endpoint, $access_key, $secret_key])) {
97 100 try {
98 101 $S3CompatibleClient = new S3Client([
99 102 'version' => '2006-03-01',
100 103 'region' => $region ? $region : 'us-east-1',
@@ -106,9 +109,9 @@
106 109 'key' => $access_key,
107 110 'secret' => $secret_key,
108 111 ],
109 112 ]);
110 -
113 +
111 114 $result = [
112 115 'success' => false,
113 116 'code' => 200,
114 117 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -167,15 +170,16 @@
167 170 * Verify Bucket
168 171 * @since 1.0.0
169 172 * @return boolean
170 173 */
171 - public function verifyBucketExist($endpoint, $access_key, $secret_key, $region, $bucket_name){
172 - if (
173 - isset($endpoint) && !empty($endpoint) &&
174 - isset($access_key) && !empty($access_key) &&
175 - isset($secret_key) && !empty($secret_key) &&
176 - isset($bucket_name) && !empty($bucket_name)
177 - ) {
174 + public function verifyBucketExist( $config = [], $bucketConfig = [] ) {
175 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
176 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
177 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
178 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
179 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
180 +
181 + if ( !Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name]) ) {
178 182 try {
179 183 $S3CompatibleClient = new S3Client([
180 184 'version' => '2006-03-01',
181 185 'region' => $region ? $region : 'us-east-1',
@@ -223,10 +227,16 @@
223 227 * Create Bucket
224 228 * @since 1.0.0
225 229 * @return boolean
226 230 */
227 - public function createBucket($endpoint, $access_key, $secret_key, $region, $bucket_name){
228 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
231 + public function createBucket( $config = [], $bucketConfig = [] ){
232 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
233 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
234 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
235 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
236 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
237 +
238 + if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $endpoint])) {
229 239 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
230 240 }
231 241
232 242 try {
@@ -247,27 +257,17 @@
247 257 $S3CompatibleClient->createBucket([
248 258 'Bucket' => $bucket_name,
249 259 ]);
250 260
251 - // Optionally wait for bucket existence (recommended)
252 - $S3CompatibleClient->waitUntil('BucketExists', ['Bucket' => $bucket_name]);
253 -
254 - try {
255 - $this->putBucketPolicy($bucket_name, $S3CompatibleClient);
256 -
257 - return [
258 - 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
259 - 'data' => [
260 - 'Name' => $bucket_name,
261 - 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
262 - ],
263 - 'code' => 200,
264 - 'success' => true,
265 - ];
266 -
267 - } catch (AwsException $ex) {
268 - 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];
269 - }
261 + return [
262 + 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
263 + 'data' => [
264 + 'Name' => $bucket_name,
265 + 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
266 + ],
267 + 'code' => 200,
268 + 'success' => true,
269 + ];
270 270 } catch (AwsException $ex) {
271 271 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
272 272 } catch (S3Exception $ex) {
273 273 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
@@ -277,71 +277,19 @@
277 277 }
278 278
279 279
280 280 /**
281 - * Add Bucket Policy
282 - */
283 - private function putBucketPolicy($bucket, $S3CompatibleClient = false) {
284 - if($S3CompatibleClient == false) {
285 - $S3CompatibleClient = $this->S3CompatibleClient;
286 - }
287 -
288 - if(empty($bucket)) return false;
289 -
290 - $policy = json_encode([
291 - "Version" => "2012-10-17",
292 - "Statement" => [
293 - [
294 - "Effect" => "Allow",
295 - "Principal" => "*",
296 - "Action" => [
297 - "s3:DeleteObjectTagging",
298 - "s3:ListBucketMultipartUploads",
299 - "s3:DeleteObjectVersion",
300 - "s3:ListBucket",
301 - "s3:DeleteObjectVersionTagging",
302 - "s3:GetBucketAcl",
303 - "s3:ListMultipartUploadParts",
304 - "s3:PutObject",
305 - "s3:GetObjectAcl",
306 - "s3:GetObject",
307 - "s3:AbortMultipartUpload",
308 - "s3:DeleteObject",
309 - "s3:GetBucketLocation",
310 - "s3:PutObjectAcl",
311 - "s3:putBucketOwnershipControls",
312 - "s3:putBucketPolicy"
313 - ],
314 - "Resource" => [
315 - "arn:aws:s3:::$bucket/*",
316 - "arn:aws:s3:::$bucket"
317 - ]
318 - ]
319 - ]
320 - ]);
321 -
322 - try {
323 - // Add bucket policy
324 - $S3CompatibleClient->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
325 -
326 - return true;
327 - } catch (AwsException $ex) {
328 - return false;
329 - } catch (S3Exception $ex) {
330 - return false;
331 - } catch (Exception $ex) {
332 - return false;
333 - }
334 - }
335 -
336 -
337 -
338 - /**
339 281 * Check Bucket Write Permission
340 282 * @since 1.0.0
341 283 */
342 - public function verifyObjectWritePermission($endpoint, $access_key, $secret_key, $region, $bucket_name){
343 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
284 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ){
285 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
286 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
287 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
288 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
289 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
290 +
291 + if (Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name])) {
344 292 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
345 293 }
346 294
347 295 try {
@@ -357,9 +305,9 @@
357 305 'secret' => $secret_key,
358 306 ],
359 307 ]);
360 308
361 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
309 + $object_key = Utils::get_permission_check_object_key();
362 310
363 311
364 312 // Create a dummy object to check write permission
365 313 $S3CompatibleClient->putObject([
@@ -367,9 +315,9 @@
367 315 'Key' => $object_key,
368 316 'Body' => 'This is a test object to check write permission.',
369 317 ]);
370 318 // Check if the object was created successfully
371 - if ($S3CompatibleClient->doesObjectExist($bucket_name, $object_key)) {
319 + if ($this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
372 320 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
373 321 } else {
374 322 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
375 323 }
@@ -388,10 +336,16 @@
388 336 /**
389 337 * Check Bucket Delete Permission
390 338 * @since 1.0.0
391 339 */
392 - public function verifyObjectDeletePermission($endpoint, $access_key, $secret_key, $region, $bucket_name){
393 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
340 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
341 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
342 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
343 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
344 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
345 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
346 +
347 + if (Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name])) {
394 348 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
395 349 }
396 350
397 351 try {
@@ -407,9 +361,9 @@
407 361 'secret' => $secret_key,
408 362 ],
409 363 ]);
410 364
411 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
365 + $object_key = Utils::get_permission_check_object_key();
412 366
413 367 // Create a dummy object to check dlete permission
414 368 $S3CompatibleClient->deleteObject([
415 369 'Bucket' => $bucket_name,
@@ -416,9 +370,9 @@
416 370 'Key' => $object_key,
417 371 ]);
418 372
419 373 // Check if the object was created successfully
420 - if (!$S3CompatibleClient->doesObjectExist($bucket_name, $object_key)) {
374 + if (!$this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
421 375 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
422 376 } else {
423 377 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
424 378 }
@@ -437,49 +391,59 @@
437 391 * Check Bucket Read Permission
438 392 * @since 1.2.4
439 393 */
440 394 public function verifyObjectReadPermission() {
441 - if (empty($this->S3CompatibleClient) || empty($this->bucket_name)) {
442 - return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false];
395 + $result = [
396 + 'status' => false,
397 + 'message' => '',
398 + 'lastChecked' => time(),
399 + ];
400 +
401 + if (Service::has_missing_fields([$this->S3CompatibleClient, $this->bucket_name])) {
402 + $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
403 + return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
443 404 }
444 405
445 406 try {
446 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
407 + $object_key = Utils::get_permission_check_object_key();
447 408
448 409 // Check if the object was created successfully
449 - if (!$this->S3CompatibleClient->doesObjectExist($this->bucket_name, $object_key)) {
410 + if (!$this->exists($object_key)) {
450 411 // Create a dummy object to check write permission
451 412 $this->S3CompatibleClient->putObject([
452 413 'Bucket' => $this->bucket_name,
453 414 'Key' => $object_key,
454 415 'Body' => 'This is a test object to check permission.',
416 + 'ContentType' => 'text/plain',
417 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
455 418 ]);
456 - }
457 -
419 + }
458 420
421 +
459 422 $url = $this->generate_file_url($object_key);
460 423 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
461 - $headers = @get_headers($cdn_url);
462 - $result = [
463 - 'status' => false,
464 - 'message' => '',
465 - 'lastChecked' => time(),
466 - ];
467 - if (strpos($headers[0], '200') !== false) {
424 + // Never trust a cached response for this fixed, predictable URL — a stale cached
425 + // error would otherwise keep failing the check long after real access is fine.
426 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
427 + $headers = @get_headers($cdn_url, false, $no_cache_context);
428 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
429 + ? (int) $matches[1]
430 + : 0;
431 +
432 + if ($status_code === 200) {
468 433 $result['status'] = true;
469 434 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
470 - } else if (strpos($headers[0], '403') !== false) {
435 + } else if ($status_code === 403) {
471 436 $result['status'] = false;
472 - if($this->cdnConfig['service'] == $this->service) {
437 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
473 438 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
474 439 } else {
475 440 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
476 441 }
477 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
478 - } else if (strpos($headers[0], '404') !== false) {
442 + } else if ($status_code === 404) {
479 443 $result['status'] = false;
480 444 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
481 - } else if (strpos($headers[0], '500') !== false) {
445 + } else if ($status_code === 500) {
482 446 $result['status'] = false;
483 447 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
484 448 } else {
485 449 $result['status'] = false;
@@ -484,9 +448,8 @@
484 448 } else {
485 449 $result['status'] = false;
486 450 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
487 451 }
488 - Utils::set_status('cdnRead', $result);
489 452
490 453 $this->deleteSingle($object_key);
491 454 return [
492 455 'message' => $result['message'],
@@ -494,13 +457,16 @@
494 457 'success' => $result['status'],
495 458 'lastChecked' => $result['lastChecked'],
496 459 ];
497 460 } catch (AwsException $ex) {
498 - return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
461 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
462 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => $result['lastChecked']];
499 463 } catch (S3Exception $ex) {
500 - return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
464 + $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
465 + return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => $result['lastChecked']];
501 466 } catch (Exception $ex) {
502 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
467 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
468 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => $result['lastChecked'] ];
503 469 }
504 470 }
505 471
506 472
@@ -517,9 +483,9 @@
517 483
518 484 // If we reach here, the credentials are valid
519 485 return true;
520 486 } catch (AwsException $ex) {
521 - $code = $e->getAwsErrorCode();
487 + $code = $ex->getAwsErrorCode();
522 488
523 489 $validErrors = [
524 490 'AccessDenied',
525 491 'NoSuchBucket',
@@ -550,8 +516,9 @@
550 516 *
551 517 */
552 518 public function toPrivate($key) {
553 519 if(!$key) return false;
520 + if(!$this->S3CompatibleClient) return false;
554 521 try {
555 522 $this->S3CompatibleClient->putObjectAcl([
556 523 'Bucket' => $this->bucket_name,
557 524 'Key' => $key,
@@ -560,9 +527,8 @@
560 527 return true;
561 528 } catch (AwsException $ex) {
562 529 return false;
563 530 }
564 - return false;
565 531 }
566 532
567 533
568 534
@@ -568,23 +534,23 @@
568 534
569 535 /**
570 536 * Make Object Public
571 537 * @since 1.0.0
572 - *
538 + *
573 539 */
574 540 public function toPublic($key) {
575 541 if(!$key) return false;
542 + if(!$this->S3CompatibleClient) return false;
576 543 try {
577 544 $this->S3CompatibleClient->putObjectAcl([
578 545 'Bucket' => $this->bucket_name,
579 546 'Key' => $key,
580 547 'ACL' => 'public-read'
581 - ]);
548 + ]);
582 549 return true;
583 550 } catch (AwsException $ex) {
584 551 return false;
585 552 }
586 - return false;
587 553 }
588 554
589 555
590 556
@@ -591,15 +557,18 @@
591 557 /**
592 558 * Check the object exist
593 559 * @since 1.1.8
594 560 */
595 - public function exists($key) {
561 + public function exists($key, $bucket_name = '', $client = null) {
596 562 if(!$key) return false;
597 563
598 564 try {
599 - if($this->S3CompatibleClient->doesObjectExist($this->bucket_name, $key)) {
565 + $bucket_name = $bucket_name ? $bucket_name : $this->bucket_name;
566 + $client = $client ?? $this->S3CompatibleClient;
567 + if($client->doesObjectExistV2($bucket_name, $key)) {
600 568 return true;
601 569 }
570 + return false;
602 571 } catch (AwsException $ex) {
603 572 return false;
604 573 } catch (S3Exception $ex) {
605 574 return false;
@@ -608,101 +577,185 @@
608 577 }
609 578 }
610 579
611 580 /**
581 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
582 + * @since 1.3.13
583 + */
584 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
585 + if (!$this->S3CompatibleClient) {
586 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
587 + }
588 + try {
589 + $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
590 + if (!empty($delimiter)) {
591 + $params['Delimiter'] = $delimiter;
592 + }
593 + if (!empty($prefix)) {
594 + $params['Prefix'] = $prefix;
595 + }
596 + if (!empty($continuationToken)) {
597 + $params['ContinuationToken'] = $continuationToken;
598 + }
599 +
600 + $result = $this->S3CompatibleClient->listObjectsV2($params);
601 + $folders = [];
602 + foreach (($result['CommonPrefixes'] ?? []) as $common) {
603 + $folders[] = $common['Prefix'];
604 + }
605 + $objects = [];
606 + foreach (($result['Contents'] ?? []) as $object) {
607 + if ($object['Key'] === $prefix) {
608 + continue; // the folder placeholder object itself, not a file
609 + }
610 + $objects[] = [
611 + 'key' => $object['Key'],
612 + 'size' => (int) $object['Size'],
613 + 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
614 + ];
615 + }
616 +
617 + return [
618 + 'success' => true,
619 + 'code' => 200,
620 + 'message' => '',
621 + 'folders' => $folders,
622 + 'objects' => $objects,
623 + 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
624 + ];
625 + } catch (AwsException $e) {
626 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
627 + } catch (Exception $e) {
628 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
629 + }
630 + }
631 +
632 + /**
612 633 * Upload Single
613 634 * @since 1.0.0
614 635 * @return boolean
615 636 */
616 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
617 - $result = array();
637 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
618 638 if (
619 - isset($media_absolute_path) && !empty($media_absolute_path) &&
620 - isset($media_path) && !empty($media_path)
639 + isset($absolute_source_path) && !empty($absolute_source_path) &&
640 + isset($relative_source_path) && !empty($relative_source_path)
621 641 ) {
622 - $file_name = wp_basename( $media_path );
642 + $file_name = wp_basename( $relative_source_path );
623 643 if ($file_name) {
624 - $upload_path = Utils::generate_object_key($media_path, $prefix);
625 -
626 - // Decide Multipart upload or normal put object
627 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
628 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
629 - try {
630 - $upload = $this->S3CompatibleClient->putObject([
631 - 'Bucket' => $this->bucket_name,
632 - 'Key' => $upload_path,
633 - 'Body' => fopen($media_absolute_path, 'r'),
634 - ]);
644 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
645 + if ($upload_path === false) {
646 + return [
647 + 'success' => false,
648 + 'code' => 200,
649 + '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')
650 + ];
651 + }
652 + return $this->execute_upload($absolute_source_path, $upload_path);
653 + }
654 + return [
655 + 'success' => false,
656 + 'code' => 200,
657 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
658 + ];
659 + }
660 + return [
661 + 'success' => false,
662 + 'code' => 200,
663 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
664 + ];
665 + }
635 666
636 - $result = array(
637 - 'success' => true,
638 - 'code' => 200,
639 - 'file_url' => $this->generate_file_url($upload_path),
640 - 'key' => $upload_path,
641 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
642 - );
643 - } catch (AwsException $e) {
644 - $result = array(
645 - 'success' => false,
646 - 'code' => 200,
647 - 'message' => $e->getMessage()
648 - );
649 - }
650 - } else {
651 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $media_absolute_path, [
652 - 'bucket' => $this->bucket_name,
653 - 'key' => $upload_path
654 - ]);
655 -
656 - try {
657 - do {
658 - try {
659 - $uploaded = $multiUploader->upload();
660 - } catch (MultipartUploadException $e) {
661 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $media_absolute_path, [
662 - 'state' => $e->getState(),
663 - ]);
664 - }
665 - } while (!isset($uploaded));
667 + /**
668 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
669 + * @since 1.4.0
670 + */
671 + public function uploadObjectAtKey($absolute_source_path, $key) {
672 + return $this->execute_upload($absolute_source_path, $key);
673 + }
666 674
667 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
668 - $result = array(
669 - 'success' => true,
670 - 'code' => 200,
671 - 'file_url' => $this->generate_file_url($upload_path),
672 - 'key' => $upload_path,
673 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
674 - );
675 - } else {
676 - $result = array(
677 - 'success' => false,
678 - 'code' => 200,
679 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
680 - );
681 - }
682 - } catch (MultipartUploadException $e) {
683 - $result = array(
684 - 'success' => false,
685 - 'code' => 200,
686 - 'message' => $e->getMessage()
687 - );
688 - }
675 + /**
676 + * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
677 + * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
678 + * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
679 + * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
680 + * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
681 + * previously-failed multipart attempt).
682 + * @since 1.4.0
683 + */
684 + private function build_object_uploader($absolute_source_path, $key, $options = []) {
685 + $handle = fopen($absolute_source_path, 'rb');
686 + $params = [];
687 + $cache_control = Utils::get_cache_control_header();
688 + if ($cache_control) {
689 + $params['CacheControl'] = $cache_control;
690 + }
691 + $options += [
692 + 'mup_threshold' => Schema::getConstant('S3COMPATIBLE_MULTIPART_MIN_FILE_SIZE'),
693 + 'params' => $params,
694 + 'before_initiate' => function ($params) { return $this->strip_acl($params); },
695 + 'before_upload' => function ($params) { return $this->strip_acl($params); },
696 + 'before_complete' => function ($params) { return $this->strip_acl($params); },
697 + ];
698 + return new ObjectUploader($this->S3CompatibleClient, $this->bucket_name, $key, $handle, null, $options);
699 + }
700 +
701 + // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
702 + // return value, relying on the same Command object being modified.
703 + private function strip_acl($params) {
704 + if ($params instanceof Command && $params->hasParam('ACL')) {
705 + unset($params['ACL']);
706 + } elseif (is_array($params) && isset($params['ACL'])) {
707 + unset($params['ACL']);
708 + }
709 + return $params;
710 + }
711 +
712 + /**
713 + * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
714 + * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
715 + * state rather than restarting the whole upload — same retry contract uploadSingle()
716 + * had before the ObjectUploader swap.
717 + * @since 1.4.0
718 + */
719 + private function execute_upload($absolute_source_path, $key) {
720 + $max_attempts = 3;
721 + $attempt = 0;
722 + $options = [];
723 +
724 + while (true) {
725 + $attempt++;
726 + try {
727 + $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
728 + return [
729 + 'success' => true,
730 + 'code' => 200,
731 + 'file_url' => $this->generate_file_url($key),
732 + 'key' => $key,
733 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
734 + ];
735 + } catch (MultipartUploadException $e) {
736 + if ($attempt >= $max_attempts) {
737 + return [
738 + 'success' => false,
739 + 'code' => 200,
740 + 'message' => $e->getMessage()
741 + ];
689 742 }
690 - } else {
691 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
692 746 'success' => false,
693 747 'code' => 200,
694 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
695 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
696 756 }
697 - } else {
698 - $result = array(
699 - 'success' => false,
700 - 'code' => 200,
701 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
702 - );
703 757 }
704 - return $result;
705 758 }
706 759
707 760 /**
708 761 * Save object to server
@@ -708,8 +761,9 @@
708 761 * Save object to server
709 762 * @since 1.0.0
710 763 */
711 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
712 766 try {
713 767 $getObject = $this->S3CompatibleClient->GetObject([
714 768 'Bucket' => $this->bucket_name,
715 769 'Key' => $key,
@@ -723,10 +777,157 @@
723 777 }
724 778 return false;
725 779 }
726 780
781 + /**
782 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
783 + * the content itself rather than a copy on the server's filesystem.
784 + * @since 1.3.13
785 + */
786 + public function get_object_content($key) {
787 + if(!$this->S3CompatibleClient) return false;
788 + try {
789 + $result = $this->S3CompatibleClient->GetObject([
790 + 'Bucket' => $this->bucket_name,
791 + 'Key' => $key,
792 + ]);
793 + return (string) $result['Body'];
794 + } catch (AwsException $e) {
795 + return false;
796 + }
797 + }
727 798
728 799 /**
800 + * Deletes the live object, then best-effort purges every historical version too — a
801 + * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
802 + * versions (and the storage they use) behind at the old key. The live delete happens
803 + * unconditionally first: not every S3-compatible endpoint supports ListObjectVersions
804 + * (confirmed missing on Cloudflare R2, a live 501 "NotImplemented"), and the object must
805 + * still end up gone either way.
806 + * @since 1.3.14
807 + */
808 + public function purge_all_versions($key) {
809 + if (!$this->S3CompatibleClient) {
810 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
811 + }
812 +
813 + try {
814 + $this->S3CompatibleClient->deleteObject([
815 + 'Bucket' => $this->bucket_name,
816 + 'Key' => $key,
817 + ]);
818 + } catch (AwsException $e) {
819 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
820 + }
821 +
822 + // Best-effort only from here — providers that don't support version listing simply
823 + // skip this part; the live object above is already gone regardless.
824 + try {
825 + $objects = [];
826 + $marker = null;
827 + do {
828 + $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
829 + if ($marker) {
830 + $args['KeyMarker'] = $marker['key'];
831 + $args['VersionIdMarker'] = $marker['version'];
832 + }
833 + $result = $this->S3CompatibleClient->listObjectVersions($args);
834 + foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
835 + if (($version['Key'] ?? null) === $key) {
836 + $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
837 + }
838 + }
839 + $marker = !empty($result['IsTruncated'])
840 + ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
841 + : null;
842 + } while ($marker);
843 +
844 + foreach (array_chunk($objects, 1000) as $chunk) {
845 + $this->S3CompatibleClient->deleteObjects([
846 + 'Bucket' => $this->bucket_name,
847 + 'Delete' => ['Objects' => $chunk],
848 + ]);
849 + }
850 + } catch (AwsException $e) {
851 + // Version history cleanup unsupported/failed — not fatal, live object is gone.
852 + }
853 +
854 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
855 + }
856 +
857 +
858 + /**
859 + * Copy to new path
860 + * @since 1.3.4
861 + */
862 + // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
863 + // exists() HEAD requests — each one is a full network round-trip, and with move/copy
864 + // processing keys sequentially, three extra round-trips per file adds up fast on a
865 + // folder with many files. copyObject() itself throws (caught below) if the source is
866 + // missing or the copy otherwise fails, so nothing is lost by not checking first.
867 + public function copy_to_new_path($key, $new_path) {
868 + if (!$this->S3CompatibleClient) {
869 + return [
870 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
871 + 'code' => 200,
872 + 'success' => false
873 + ];
874 + }
875 + try {
876 + $this->S3CompatibleClient->copyObject([
877 + 'Bucket' => $this->bucket_name,
878 + 'CopySource' => "{$this->bucket_name}/{$key}",
879 + 'Key' => $new_path,
880 + 'MetadataDirective' => 'COPY',
881 + ]);
882 + return [
883 + 'success' => true,
884 + 'code' => 200,
885 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
886 + ];
887 + } catch (AwsException $e) {
888 + return [
889 + 'success' => false,
890 + 'code' => 200,
891 + 'message' => $e->getMessage()
892 + ];
893 + }
894 + }
895 +
896 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
897 + // access there too (and the same endpoint), so callers should fall back to download+upload
898 + // on failure.
899 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
900 + if (!$this->S3CompatibleClient) {
901 + return [
902 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
903 + 'code' => 200,
904 + 'success' => false
905 + ];
906 + }
907 + try {
908 + $this->S3CompatibleClient->copyObject([
909 + 'Bucket' => $dest_bucket,
910 + 'CopySource' => "{$this->bucket_name}/{$key}",
911 + 'Key' => $new_key,
912 + 'MetadataDirective' => 'COPY',
913 + ]);
914 + return [
915 + 'success' => true,
916 + 'code' => 200,
917 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
918 + ];
919 + } catch (AwsException $e) {
920 + return [
921 + 'success' => false,
922 + 'code' => 200,
923 + 'message' => $e->getMessage()
924 + ];
925 + }
926 + }
927 +
928 +
929 + /**
729 930 * Delete Single
730 931 * @since 1.0.0
731 932 * @return boolean
732 933 */
@@ -731,8 +932,15 @@
731 932 * @return boolean
732 933 */
733 934 public function deleteSingle($key) {
734 935 $result = array();
936 + if (!$this->S3CompatibleClient) {
937 + return array(
938 + 'success' => false,
939 + 'code' => 200,
940 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
941 + );
942 + }
735 943 if (isset($key) && !empty($key)) {
736 944 try {
737 945 $this->S3CompatibleClient->deleteObject([
738 946 'Bucket' => $this->bucket_name,
@@ -738,9 +946,9 @@
738 946 'Bucket' => $this->bucket_name,
739 947 'Key' => $key
740 948 ]);
741 949
742 - if (!$this->S3CompatibleClient->doesObjectExist($this->bucket_name, $key)) {
950 + if (!$this->exists($key)) {
743 951 $result = array(
744 952 'success' => true,
745 953 'code' => 200,
746 954 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -769,14 +977,21 @@
769 977 return $result;
770 978 }
771 979
772 980 /**
773 - * get presigned URL
981 + * get private URL
774 982 * @since 1.0.0
775 983 * @return boolean
776 984 */
777 - public function get_presigned_url($key) {
985 + public function get_private_url($key) {
778 986 $result = array();
987 + if (!$this->S3CompatibleClient) {
988 + return array(
989 + 'success' => false,
990 + 'code' => 200,
991 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
992 + );
993 + }
779 994 if (isset($key) && !empty($key)) {
780 995 try {
781 996 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
782 997 'Bucket' => $this->bucket_name,
@@ -782,24 +997,24 @@
782 997 'Bucket' => $this->bucket_name,
783 998 'Key' => $key
784 999 ]);
785 1000
786 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1001 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
787 1002
788 1003 $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
789 1004
790 - if ($presignedUrl = (string)$request->getUri()) {
1005 + if ($privateUrl = (string)$request->getUri()) {
791 1006 $result = array(
792 1007 'success' => true,
793 1008 'code' => 200,
794 - 'file_url' => $presignedUrl,
795 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1009 + 'file_url' => $privateUrl,
1010 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
796 1011 );
797 1012 } else {
798 1013 $result = array(
799 1014 'success' => false,
800 1015 'code' => 200,
801 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1016 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
802 1017 );
803 1018 }
804 1019 } catch (AwsException $e) {
805 1020 $result = array(
@@ -832,9 +1047,9 @@
832 1047
833 1048 /**
834 1049 * Generate file URL
835 1050 */
836 - private function generate_file_url($key){
1051 + public function generate_file_url($key){
837 1052 $domain = $this->get_domain();
838 1053
839 1054 return apply_filters('wpmcs_generate_do_file_url',
840 1055 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -844,11 +1059,25 @@
844 1059 );
845 1060 }
846 1061
847 1062 /**
1063 + * Is Provider URL
1064 + * @since 1.3.6
1065 + */
1066 + public function is_provider_url($url) {
1067 + $domain = $this->get_domain();
1068 + return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
1069 + }
1070 +
1071 + /**
848 1072 * Get domain URL
849 1073 */
850 1074 public function get_domain($region = '') {
851 1075 return $this->config['endpoint'];
1076 + }
1077 +
1078 + /** Exposes the already-constructed SDK client for StreamWrapper's registration — avoids reconstructing one from credentials. */
1079 + public function get_client() {
1080 + return $this->S3CompatibleClient;
852 1081 }
853 1082
854 1083 }