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