PluginProbe
Media Cloud Sync / trunk
Media Cloud Sync vtrunk
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/s3compatible.php +448 -223 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 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 ) {
@@ -86,14 +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 = false){
91 - if (
92 - isset($access_key) && !empty($access_key) &&
93 - isset($secret_key) && !empty($secret_key) &&
94 - isset($endpoint) && !empty($endpoint)
95 - ) {
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])) {
96 100 try {
97 101 $S3CompatibleClient = new S3Client([
98 102 'version' => '2006-03-01',
99 103 'region' => $region ? $region : 'us-east-1',
@@ -105,9 +109,9 @@
105 109 'key' => $access_key,
106 110 'secret' => $secret_key,
107 111 ],
108 112 ]);
109 -
113 +
110 114 $result = [
111 115 'success' => false,
112 116 'code' => 200,
113 117 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -166,15 +170,16 @@
166 170 * Verify Bucket
167 171 * @since 1.0.0
168 172 * @return boolean
169 173 */
170 - public function verifyBucketExist($endpoint, $access_key, $secret_key, $bucket_name, $region = false){
171 - if (
172 - isset($endpoint) && !empty($endpoint) &&
173 - isset($access_key) && !empty($access_key) &&
174 - isset($secret_key) && !empty($secret_key) &&
175 - isset($bucket_name) && !empty($bucket_name)
176 - ) {
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]) ) {
177 182 try {
178 183 $S3CompatibleClient = new S3Client([
179 184 'version' => '2006-03-01',
180 185 'region' => $region ? $region : 'us-east-1',
@@ -222,10 +227,16 @@
222 227 * Create Bucket
223 228 * @since 1.0.0
224 229 * @return boolean
225 230 */
226 - public function createBucket($endpoint, $access_key, $secret_key, $bucket_name, $region = false){
227 - 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])) {
228 239 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
229 240 }
230 241
231 242 try {
@@ -246,27 +257,17 @@
246 257 $S3CompatibleClient->createBucket([
247 258 'Bucket' => $bucket_name,
248 259 ]);
249 260
250 - // Optionally wait for bucket existence (recommended)
251 - $S3CompatibleClient->waitUntil('BucketExists', ['Bucket' => $bucket_name]);
252 -
253 - try {
254 - $this->putBucketPolicy($bucket_name, $S3CompatibleClient);
255 -
256 - return [
257 - 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
258 - 'data' => [
259 - 'Name' => $bucket_name,
260 - 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
261 - ],
262 - 'code' => 200,
263 - 'success' => true,
264 - ];
265 -
266 - } catch (AwsException $ex) {
267 - 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];
268 - }
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 + ];
269 270 } catch (AwsException $ex) {
270 271 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
271 272 } catch (S3Exception $ex) {
272 273 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
@@ -276,71 +277,19 @@
276 277 }
277 278
278 279
279 280 /**
280 - * Add Bucket Policy
281 - */
282 - private function putBucketPolicy($bucket, $S3CompatibleClient = false) {
283 - if($S3CompatibleClient == false) {
284 - $S3CompatibleClient = $this->S3CompatibleClient;
285 - }
286 -
287 - if(empty($bucket)) return false;
288 -
289 - $policy = json_encode([
290 - "Version" => "2012-10-17",
291 - "Statement" => [
292 - [
293 - "Effect" => "Allow",
294 - "Principal" => "*",
295 - "Action" => [
296 - "s3:DeleteObjectTagging",
297 - "s3:ListBucketMultipartUploads",
298 - "s3:DeleteObjectVersion",
299 - "s3:ListBucket",
300 - "s3:DeleteObjectVersionTagging",
301 - "s3:GetBucketAcl",
302 - "s3:ListMultipartUploadParts",
303 - "s3:PutObject",
304 - "s3:GetObjectAcl",
305 - "s3:GetObject",
306 - "s3:AbortMultipartUpload",
307 - "s3:DeleteObject",
308 - "s3:GetBucketLocation",
309 - "s3:PutObjectAcl",
310 - "s3:putBucketOwnershipControls",
311 - "s3:putBucketPolicy"
312 - ],
313 - "Resource" => [
314 - "arn:aws:s3:::$bucket/*",
315 - "arn:aws:s3:::$bucket"
316 - ]
317 - ]
318 - ]
319 - ]);
320 -
321 - try {
322 - // Add bucket policy
323 - $S3CompatibleClient->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
324 -
325 - return true;
326 - } catch (AwsException $ex) {
327 - return false;
328 - } catch (S3Exception $ex) {
329 - return false;
330 - } catch (Exception $ex) {
331 - return false;
332 - }
333 - }
334 -
335 -
336 -
337 - /**
338 281 * Check Bucket Write Permission
339 282 * @since 1.0.0
340 283 */
341 - public function verifyObjectWritePermission($endpoint, $access_key, $secret_key, $bucket_name, $region = false){
342 - 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])) {
343 292 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
344 293 }
345 294
346 295 try {
@@ -356,9 +305,9 @@
356 305 'secret' => $secret_key,
357 306 ],
358 307 ]);
359 308
360 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
309 + $object_key = Utils::get_permission_check_object_key();
361 310
362 311
363 312 // Create a dummy object to check write permission
364 313 $S3CompatibleClient->putObject([
@@ -366,9 +315,9 @@
366 315 'Key' => $object_key,
367 316 'Body' => 'This is a test object to check write permission.',
368 317 ]);
369 318 // Check if the object was created successfully
370 - if ($S3CompatibleClient->doesObjectExist($bucket_name, $object_key)) {
319 + if ($this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
371 320 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
372 321 } else {
373 322 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
374 323 }
@@ -387,10 +336,16 @@
387 336 /**
388 337 * Check Bucket Delete Permission
389 338 * @since 1.0.0
390 339 */
391 - public function verifyObjectDeletePermission($endpoint, $access_key, $secret_key, $bucket_name, $region = false){
392 - 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])) {
393 348 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
394 349 }
395 350
396 351 try {
@@ -406,9 +361,9 @@
406 361 'secret' => $secret_key,
407 362 ],
408 363 ]);
409 364
410 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
365 + $object_key = Utils::get_permission_check_object_key();
411 366
412 367 // Create a dummy object to check dlete permission
413 368 $S3CompatibleClient->deleteObject([
414 369 'Bucket' => $bucket_name,
@@ -415,9 +370,9 @@
415 370 'Key' => $object_key,
416 371 ]);
417 372
418 373 // Check if the object was created successfully
419 - if (!$S3CompatibleClient->doesObjectExist($bucket_name, $object_key)) {
374 + if (!$this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
420 375 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
421 376 } else {
422 377 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
423 378 }
@@ -436,49 +391,59 @@
436 391 * Check Bucket Read Permission
437 392 * @since 1.2.4
438 393 */
439 394 public function verifyObjectReadPermission() {
440 - if (empty($this->S3CompatibleClient) || empty($this->bucket_name)) {
441 - 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()];
442 404 }
443 405
444 406 try {
445 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
407 + $object_key = Utils::get_permission_check_object_key();
446 408
447 409 // Check if the object was created successfully
448 - if (!$this->S3CompatibleClient->doesObjectExist($this->bucket_name, $object_key)) {
410 + if (!$this->exists($object_key)) {
449 411 // Create a dummy object to check write permission
450 412 $this->S3CompatibleClient->putObject([
451 413 'Bucket' => $this->bucket_name,
452 414 'Key' => $object_key,
453 415 'Body' => 'This is a test object to check permission.',
416 + 'ContentType' => 'text/plain',
417 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
454 418 ]);
455 - }
456 -
419 + }
457 420
421 +
458 422 $url = $this->generate_file_url($object_key);
459 423 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
460 - $headers = @get_headers($cdn_url);
461 - $result = [
462 - 'status' => false,
463 - 'message' => '',
464 - 'lastChecked' => time(),
465 - ];
466 - 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) {
467 433 $result['status'] = true;
468 434 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
469 - } else if (strpos($headers[0], '403') !== false) {
435 + } else if ($status_code === 403) {
470 436 $result['status'] = false;
471 - if($this->cdnConfig['service'] == $this->service) {
437 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
472 438 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
473 439 } else {
474 440 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
475 441 }
476 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
477 - } else if (strpos($headers[0], '404') !== false) {
442 + } else if ($status_code === 404) {
478 443 $result['status'] = false;
479 444 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
480 - } else if (strpos($headers[0], '500') !== false) {
445 + } else if ($status_code === 500) {
481 446 $result['status'] = false;
482 447 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
483 448 } else {
484 449 $result['status'] = false;
@@ -483,9 +448,8 @@
483 448 } else {
484 449 $result['status'] = false;
485 450 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
486 451 }
487 - Utils::set_status('cdnRead', $result);
488 452
489 453 $this->deleteSingle($object_key);
490 454 return [
491 455 'message' => $result['message'],
@@ -493,13 +457,16 @@
493 457 'success' => $result['status'],
494 458 'lastChecked' => $result['lastChecked'],
495 459 ];
496 460 } catch (AwsException $ex) {
497 - 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']];
498 463 } catch (S3Exception $ex) {
499 - 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']];
500 466 } catch (Exception $ex) {
501 - 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'] ];
502 469 }
503 470 }
504 471
505 472
@@ -516,9 +483,9 @@
516 483
517 484 // If we reach here, the credentials are valid
518 485 return true;
519 486 } catch (AwsException $ex) {
520 - $code = $e->getAwsErrorCode();
487 + $code = $ex->getAwsErrorCode();
521 488
522 489 $validErrors = [
523 490 'AccessDenied',
524 491 'NoSuchBucket',
@@ -549,8 +516,9 @@
549 516 *
550 517 */
551 518 public function toPrivate($key) {
552 519 if(!$key) return false;
520 + if(!$this->S3CompatibleClient) return false;
553 521 try {
554 522 $this->S3CompatibleClient->putObjectAcl([
555 523 'Bucket' => $this->bucket_name,
556 524 'Key' => $key,
@@ -559,9 +527,8 @@
559 527 return true;
560 528 } catch (AwsException $ex) {
561 529 return false;
562 530 }
563 - return false;
564 531 }
565 532
566 533
567 534
@@ -567,23 +534,23 @@
567 534
568 535 /**
569 536 * Make Object Public
570 537 * @since 1.0.0
571 - *
538 + *
572 539 */
573 540 public function toPublic($key) {
574 541 if(!$key) return false;
542 + if(!$this->S3CompatibleClient) return false;
575 543 try {
576 544 $this->S3CompatibleClient->putObjectAcl([
577 545 'Bucket' => $this->bucket_name,
578 546 'Key' => $key,
579 547 'ACL' => 'public-read'
580 - ]);
548 + ]);
581 549 return true;
582 550 } catch (AwsException $ex) {
583 551 return false;
584 552 }
585 - return false;
586 553 }
587 554
588 555
589 556
@@ -590,15 +557,18 @@
590 557 /**
591 558 * Check the object exist
592 559 * @since 1.1.8
593 560 */
594 - public function exists($key) {
561 + public function exists($key, $bucket_name = '', $client = null) {
595 562 if(!$key) return false;
596 563
597 564 try {
598 - 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)) {
599 568 return true;
600 569 }
570 + return false;
601 571 } catch (AwsException $ex) {
602 572 return false;
603 573 } catch (S3Exception $ex) {
604 574 return false;
@@ -607,101 +577,185 @@
607 577 }
608 578 }
609 579
610 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 + /**
611 633 * Upload Single
612 634 * @since 1.0.0
613 635 * @return boolean
614 636 */
615 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
616 - $result = array();
637 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
617 638 if (
618 - isset($media_absolute_path) && !empty($media_absolute_path) &&
619 - isset($media_path) && !empty($media_path)
639 + isset($absolute_source_path) && !empty($absolute_source_path) &&
640 + isset($relative_source_path) && !empty($relative_source_path)
620 641 ) {
621 - $file_name = wp_basename( $media_path );
642 + $file_name = wp_basename( $relative_source_path );
622 643 if ($file_name) {
623 - $upload_path = Utils::generate_object_key($media_path, $prefix);
624 -
625 - // Decide Multipart upload or normal put object
626 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
627 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
628 - try {
629 - $upload = $this->S3CompatibleClient->putObject([
630 - 'Bucket' => $this->bucket_name,
631 - 'Key' => $upload_path,
632 - 'Body' => fopen($media_absolute_path, 'r'),
633 - ]);
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 + }
634 666
635 - $result = array(
636 - 'success' => true,
637 - 'code' => 200,
638 - 'file_url' => $this->generate_file_url($upload_path),
639 - 'key' => $upload_path,
640 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
641 - );
642 - } catch (AwsException $e) {
643 - $result = array(
644 - 'success' => false,
645 - 'code' => 200,
646 - 'message' => $e->getMessage()
647 - );
648 - }
649 - } else {
650 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $media_absolute_path, [
651 - 'bucket' => $this->bucket_name,
652 - 'key' => $upload_path
653 - ]);
654 -
655 - try {
656 - do {
657 - try {
658 - $uploaded = $multiUploader->upload();
659 - } catch (MultipartUploadException $e) {
660 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $media_absolute_path, [
661 - 'state' => $e->getState(),
662 - ]);
663 - }
664 - } 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 + }
665 674
666 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
667 - $result = array(
668 - 'success' => true,
669 - 'code' => 200,
670 - 'file_url' => $this->generate_file_url($upload_path),
671 - 'key' => $upload_path,
672 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
673 - );
674 - } else {
675 - $result = array(
676 - 'success' => false,
677 - 'code' => 200,
678 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
679 - );
680 - }
681 - } catch (MultipartUploadException $e) {
682 - $result = array(
683 - 'success' => false,
684 - 'code' => 200,
685 - 'message' => $e->getMessage()
686 - );
687 - }
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 + ];
688 742 }
689 - } else {
690 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
691 746 'success' => false,
692 747 'code' => 200,
693 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
694 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
695 756 }
696 - } else {
697 - $result = array(
698 - 'success' => false,
699 - 'code' => 200,
700 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
701 - );
702 757 }
703 - return $result;
704 758 }
705 759
706 760 /**
707 761 * Save object to server
@@ -707,8 +761,9 @@
707 761 * Save object to server
708 762 * @since 1.0.0
709 763 */
710 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
711 766 try {
712 767 $getObject = $this->S3CompatibleClient->GetObject([
713 768 'Bucket' => $this->bucket_name,
714 769 'Key' => $key,
@@ -722,10 +777,157 @@
722 777 }
723 778 return false;
724 779 }
725 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 + }
726 798
727 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 + /**
728 930 * Delete Single
729 931 * @since 1.0.0
730 932 * @return boolean
731 933 */
@@ -730,8 +932,15 @@
730 932 * @return boolean
731 933 */
732 934 public function deleteSingle($key) {
733 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 + }
734 943 if (isset($key) && !empty($key)) {
735 944 try {
736 945 $this->S3CompatibleClient->deleteObject([
737 946 'Bucket' => $this->bucket_name,
@@ -737,9 +946,9 @@
737 946 'Bucket' => $this->bucket_name,
738 947 'Key' => $key
739 948 ]);
740 949
741 - if (!$this->S3CompatibleClient->doesObjectExist($this->bucket_name, $key)) {
950 + if (!$this->exists($key)) {
742 951 $result = array(
743 952 'success' => true,
744 953 'code' => 200,
745 954 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -768,14 +977,21 @@
768 977 return $result;
769 978 }
770 979
771 980 /**
772 - * get presigned URL
981 + * get private URL
773 982 * @since 1.0.0
774 983 * @return boolean
775 984 */
776 - public function get_presigned_url($key) {
985 + public function get_private_url($key) {
777 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 + }
778 994 if (isset($key) && !empty($key)) {
779 995 try {
780 996 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
781 997 'Bucket' => $this->bucket_name,
@@ -781,24 +997,24 @@
781 997 'Bucket' => $this->bucket_name,
782 998 'Key' => $key
783 999 ]);
784 1000
785 - $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;
786 1002
787 1003 $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
788 1004
789 - if ($presignedUrl = (string)$request->getUri()) {
1005 + if ($privateUrl = (string)$request->getUri()) {
790 1006 $result = array(
791 1007 'success' => true,
792 1008 'code' => 200,
793 - 'file_url' => $presignedUrl,
794 - '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')
795 1011 );
796 1012 } else {
797 1013 $result = array(
798 1014 'success' => false,
799 1015 'code' => 200,
800 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1016 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
801 1017 );
802 1018 }
803 1019 } catch (AwsException $e) {
804 1020 $result = array(
@@ -831,9 +1047,9 @@
831 1047
832 1048 /**
833 1049 * Generate file URL
834 1050 */
835 - private function generate_file_url($key){
1051 + public function generate_file_url($key){
836 1052 $domain = $this->get_domain();
837 1053
838 1054 return apply_filters('wpmcs_generate_do_file_url',
839 1055 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -840,8 +1056,17 @@
840 1056 $domain,
841 1057 $this->bucket_name,
842 1058 $key
843 1059 );
1060 + }
1061 +
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);
844 1069 }
845 1070
846 1071 /**
847 1072 * Get domain URL