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 +502 -228 1.2.5 → 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'),
@@ -133,8 +136,9 @@
133 136 'NoSuchBucket',
134 137 'AllAccessDisabled',
135 138 'AuthorizationHeaderMalformed',
136 139 'PermanentRedirect',
140 + 'InvalidBucketName',
137 141 ];
138 142
139 143 if (in_array($code, $validErrors)) {
140 144 // If we reach here, the credentials are valid
@@ -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, $region, $bucket_name){
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, $region, $bucket_name){
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,63 +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 - // Add bucket policy
322 - $S3CompatibleClient->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
323 -
324 - return true;
325 - }
326 -
327 -
328 -
329 - /**
330 281 * Check Bucket Write Permission
331 282 * @since 1.0.0
332 283 */
333 - public function verifyObjectWritePermission($endpoint, $access_key, $secret_key, $region, $bucket_name){
334 - 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])) {
335 292 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
336 293 }
337 294
338 295 try {
@@ -348,9 +305,9 @@
348 305 'secret' => $secret_key,
349 306 ],
350 307 ]);
351 308
352 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
309 + $object_key = Utils::get_permission_check_object_key();
353 310
354 311
355 312 // Create a dummy object to check write permission
356 313 $S3CompatibleClient->putObject([
@@ -358,9 +315,9 @@
358 315 'Key' => $object_key,
359 316 'Body' => 'This is a test object to check write permission.',
360 317 ]);
361 318 // Check if the object was created successfully
362 - if ($S3CompatibleClient->doesObjectExist($bucket_name, $object_key)) {
319 + if ($this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
363 320 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
364 321 } else {
365 322 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
366 323 }
@@ -379,10 +336,16 @@
379 336 /**
380 337 * Check Bucket Delete Permission
381 338 * @since 1.0.0
382 339 */
383 - public function verifyObjectDeletePermission($endpoint, $access_key, $secret_key, $region, $bucket_name){
384 - 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])) {
385 348 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
386 349 }
387 350
388 351 try {
@@ -398,9 +361,9 @@
398 361 'secret' => $secret_key,
399 362 ],
400 363 ]);
401 364
402 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
365 + $object_key = Utils::get_permission_check_object_key();
403 366
404 367 // Create a dummy object to check dlete permission
405 368 $S3CompatibleClient->deleteObject([
406 369 'Bucket' => $bucket_name,
@@ -407,9 +370,9 @@
407 370 'Key' => $object_key,
408 371 ]);
409 372
410 373 // Check if the object was created successfully
411 - if (!$S3CompatibleClient->doesObjectExist($bucket_name, $object_key)) {
374 + if (!$this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
412 375 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
413 376 } else {
414 377 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
415 378 }
@@ -428,49 +391,59 @@
428 391 * Check Bucket Read Permission
429 392 * @since 1.2.4
430 393 */
431 394 public function verifyObjectReadPermission() {
432 - if (empty($this->S3CompatibleClient) || empty($this->bucket_name)) {
433 - 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()];
434 404 }
435 405
436 406 try {
437 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
407 + $object_key = Utils::get_permission_check_object_key();
438 408
439 409 // Check if the object was created successfully
440 - if (!$this->S3CompatibleClient->doesObjectExist($this->bucket_name, $object_key)) {
410 + if (!$this->exists($object_key)) {
441 411 // Create a dummy object to check write permission
442 412 $this->S3CompatibleClient->putObject([
443 413 'Bucket' => $this->bucket_name,
444 414 'Key' => $object_key,
445 415 'Body' => 'This is a test object to check permission.',
416 + 'ContentType' => 'text/plain',
417 + 'CacheControl' => 'no-cache, no-store, must-revalidate',
446 418 ]);
447 - }
448 -
419 + }
449 420
421 +
450 422 $url = $this->generate_file_url($object_key);
451 423 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
452 - $headers = @get_headers($cdn_url);
453 - $result = [
454 - 'status' => false,
455 - 'message' => '',
456 - 'lastChecked' => time(),
457 - ];
458 - 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) {
459 433 $result['status'] = true;
460 434 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
461 - } else if (strpos($headers[0], '403') !== false) {
435 + } else if ($status_code === 403) {
462 436 $result['status'] = false;
463 - if($this->cdnConfig['service'] == $this->service) {
437 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
464 438 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
465 439 } else {
466 440 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
467 441 }
468 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
469 - } else if (strpos($headers[0], '404') !== false) {
442 + } else if ($status_code === 404) {
470 443 $result['status'] = false;
471 444 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
472 - } else if (strpos($headers[0], '500') !== false) {
445 + } else if ($status_code === 500) {
473 446 $result['status'] = false;
474 447 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
475 448 } else {
476 449 $result['status'] = false;
@@ -475,9 +448,8 @@
475 448 } else {
476 449 $result['status'] = false;
477 450 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
478 451 }
479 - Utils::set_status('cdnRead', $result);
480 452
481 453 $this->deleteSingle($object_key);
482 454 return [
483 455 'message' => $result['message'],
@@ -485,16 +457,20 @@
485 457 'success' => $result['status'],
486 458 'lastChecked' => $result['lastChecked'],
487 459 ];
488 460 } catch (AwsException $ex) {
489 - 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']];
490 463 } catch (S3Exception $ex) {
491 - 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']];
492 466 } catch (Exception $ex) {
493 - 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'] ];
494 469 }
495 470 }
496 471
472 +
497 473 /**
498 474 * isConfigured Function To Identify the congfigurations are correct
499 475 * @since 1.0.0
500 476 */
@@ -500,18 +476,35 @@
500 476 */
501 477 public function isConfigured(){
502 478 if ($this->S3CompatibleClient) {
503 479 try {
504 - $buckets = $S3CompatibleClient->listBuckets();
505 - if(!empty($buckets)){
506 - foreach($buckets as $bucket) {
507 - if ($bucket['Name']==$this->bucket_name) {
508 - return true;
509 - }
510 - }
511 - }
480 + $this->S3CompatibleClient->listObjectsV2([
481 + 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
482 + ]);
483 +
484 + // If we reach here, the credentials are valid
485 + return true;
486 + } catch (AwsException $ex) {
487 + $code = $ex->getAwsErrorCode();
488 +
489 + $validErrors = [
490 + 'AccessDenied',
491 + 'NoSuchBucket',
492 + 'AllAccessDisabled',
493 + 'AuthorizationHeaderMalformed',
494 + 'PermanentRedirect',
495 + 'InvalidBucketName'
496 + ];
497 +
498 + if (in_array($code, $validErrors)) {
499 + // If we reach here, the credentials are valid
500 + return true;
501 + } else {
502 + return false;
503 + }
504 + } catch (S3Exception $ex) {
512 505 return false;
513 - } catch (AwsException $ex) {
506 + } catch (Exception $ex) {
514 507 return false;
515 508 }
516 509 }
517 510 return false;
@@ -523,8 +516,9 @@
523 516 *
524 517 */
525 518 public function toPrivate($key) {
526 519 if(!$key) return false;
520 + if(!$this->S3CompatibleClient) return false;
527 521 try {
528 522 $this->S3CompatibleClient->putObjectAcl([
529 523 'Bucket' => $this->bucket_name,
530 524 'Key' => $key,
@@ -533,9 +527,8 @@
533 527 return true;
534 528 } catch (AwsException $ex) {
535 529 return false;
536 530 }
537 - return false;
538 531 }
539 532
540 533
541 534
@@ -541,23 +534,23 @@
541 534
542 535 /**
543 536 * Make Object Public
544 537 * @since 1.0.0
545 - *
538 + *
546 539 */
547 540 public function toPublic($key) {
548 541 if(!$key) return false;
542 + if(!$this->S3CompatibleClient) return false;
549 543 try {
550 544 $this->S3CompatibleClient->putObjectAcl([
551 545 'Bucket' => $this->bucket_name,
552 546 'Key' => $key,
553 547 'ACL' => 'public-read'
554 - ]);
548 + ]);
555 549 return true;
556 550 } catch (AwsException $ex) {
557 551 return false;
558 552 }
559 - return false;
560 553 }
561 554
562 555
563 556
@@ -564,112 +557,205 @@
564 557 /**
565 558 * Check the object exist
566 559 * @since 1.1.8
567 560 */
568 - public function exists($key) {
561 + public function exists($key, $bucket_name = '', $client = null) {
569 562 if(!$key) return false;
570 563
571 - if($this->S3CompatibleClient->doesObjectExist($this->bucket_name, $key)) {
572 - return true;
564 + try {
565 + $bucket_name = $bucket_name ? $bucket_name : $this->bucket_name;
566 + $client = $client ?? $this->S3CompatibleClient;
567 + if($client->doesObjectExistV2($bucket_name, $key)) {
568 + return true;
569 + }
570 + return false;
571 + } catch (AwsException $ex) {
572 + return false;
573 + } catch (S3Exception $ex) {
574 + return false;
575 + } catch (Exception $ex) {
576 + return false;
573 577 }
574 -
575 - return false;
576 578 }
577 579
578 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 + /**
579 633 * Upload Single
580 634 * @since 1.0.0
581 635 * @return boolean
582 636 */
583 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
584 - $result = array();
637 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
585 638 if (
586 - isset($media_absolute_path) && !empty($media_absolute_path) &&
587 - isset($media_path) && !empty($media_path)
639 + isset($absolute_source_path) && !empty($absolute_source_path) &&
640 + isset($relative_source_path) && !empty($relative_source_path)
588 641 ) {
589 - $file_name = wp_basename( $media_path );
642 + $file_name = wp_basename( $relative_source_path );
590 643 if ($file_name) {
591 - $upload_path = Utils::generate_object_key($media_path, $prefix);
592 -
593 - // Decide Multipart upload or normal put object
594 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
595 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
596 - try {
597 - $upload = $this->S3CompatibleClient->putObject([
598 - 'Bucket' => $this->bucket_name,
599 - 'Key' => $upload_path,
600 - 'Body' => fopen($media_absolute_path, 'r'),
601 - ]);
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 + }
602 666
603 - $result = array(
604 - 'success' => true,
605 - 'code' => 200,
606 - 'file_url' => $this->generate_file_url($upload_path),
607 - 'key' => $upload_path,
608 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
609 - );
610 - } catch (AwsException $e) {
611 - $result = array(
612 - 'success' => false,
613 - 'code' => 200,
614 - 'message' => $e->getMessage()
615 - );
616 - }
617 - } else {
618 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $media_absolute_path, [
619 - 'bucket' => $this->bucket_name,
620 - 'key' => $upload_path
621 - ]);
622 -
623 - try {
624 - do {
625 - try {
626 - $uploaded = $multiUploader->upload();
627 - } catch (MultipartUploadException $e) {
628 - $multiUploader = new MultipartUploader($this->S3CompatibleClient, $media_absolute_path, [
629 - 'state' => $e->getState(),
630 - ]);
631 - }
632 - } 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 + }
633 674
634 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
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 - } else {
643 - $result = array(
644 - 'success' => false,
645 - 'code' => 200,
646 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
647 - );
648 - }
649 - } catch (MultipartUploadException $e) {
650 - $result = array(
651 - 'success' => false,
652 - 'code' => 200,
653 - 'message' => $e->getMessage()
654 - );
655 - }
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 + ];
656 742 }
657 - } else {
658 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
659 746 'success' => false,
660 747 'code' => 200,
661 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
662 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
663 756 }
664 - } else {
665 - $result = array(
666 - 'success' => false,
667 - 'code' => 200,
668 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
669 - );
670 757 }
671 - return $result;
672 758 }
673 759
674 760 /**
675 761 * Save object to server
@@ -675,8 +761,9 @@
675 761 * Save object to server
676 762 * @since 1.0.0
677 763 */
678 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
679 766 try {
680 767 $getObject = $this->S3CompatibleClient->GetObject([
681 768 'Bucket' => $this->bucket_name,
682 769 'Key' => $key,
@@ -690,10 +777,157 @@
690 777 }
691 778 return false;
692 779 }
693 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 + }
694 798
695 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 + /**
696 930 * Delete Single
697 931 * @since 1.0.0
698 932 * @return boolean
699 933 */
@@ -698,8 +932,15 @@
698 932 * @return boolean
699 933 */
700 934 public function deleteSingle($key) {
701 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 + }
702 943 if (isset($key) && !empty($key)) {
703 944 try {
704 945 $this->S3CompatibleClient->deleteObject([
705 946 'Bucket' => $this->bucket_name,
@@ -705,9 +946,9 @@
705 946 'Bucket' => $this->bucket_name,
706 947 'Key' => $key
707 948 ]);
708 949
709 - if (!$this->S3CompatibleClient->doesObjectExist($this->bucket_name, $key)) {
950 + if (!$this->exists($key)) {
710 951 $result = array(
711 952 'success' => true,
712 953 'code' => 200,
713 954 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -736,14 +977,21 @@
736 977 return $result;
737 978 }
738 979
739 980 /**
740 - * get presigned URL
981 + * get private URL
741 982 * @since 1.0.0
742 983 * @return boolean
743 984 */
744 - public function get_presigned_url($key) {
985 + public function get_private_url($key) {
745 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 + }
746 994 if (isset($key) && !empty($key)) {
747 995 try {
748 996 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
749 997 'Bucket' => $this->bucket_name,
@@ -749,24 +997,24 @@
749 997 'Bucket' => $this->bucket_name,
750 998 'Key' => $key
751 999 ]);
752 1000
753 - $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;
754 1002
755 1003 $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
756 1004
757 - if ($presignedUrl = (string)$request->getUri()) {
1005 + if ($privateUrl = (string)$request->getUri()) {
758 1006 $result = array(
759 1007 'success' => true,
760 1008 'code' => 200,
761 - 'file_url' => $presignedUrl,
762 - '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')
763 1011 );
764 1012 } else {
765 1013 $result = array(
766 1014 'success' => false,
767 1015 'code' => 200,
768 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1016 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
769 1017 );
770 1018 }
771 1019 } catch (AwsException $e) {
772 1020 $result = array(
@@ -773,8 +1021,20 @@
773 1021 'success' => false,
774 1022 'code' => 200,
775 1023 'message' => $e->getMessage()
776 1024 );
1025 + } catch (S3Exception $e) {
1026 + $result = array(
1027 + 'success' => false,
1028 + 'code' => 200,
1029 + 'message' => $e->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync')
1030 + );
1031 + } catch (Exception $e) {
1032 + $result = array(
1033 + 'success' => false,
1034 + 'code' => 200,
1035 + 'message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync')
1036 + );
777 1037 }
778 1038 } else {
779 1039 $result = array(
780 1040 'success' => false,
@@ -787,9 +1047,9 @@
787 1047
788 1048 /**
789 1049 * Generate file URL
790 1050 */
791 - private function generate_file_url($key){
1051 + public function generate_file_url($key){
792 1052 $domain = $this->get_domain();
793 1053
794 1054 return apply_filters('wpmcs_generate_do_file_url',
795 1055 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -799,11 +1059,25 @@
799 1059 );
800 1060 }
801 1061
802 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 + /**
803 1072 * Get domain URL
804 1073 */
805 1074 public function get_domain($region = '') {
806 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;
807 1081 }
808 1082
809 1083 }