PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
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
media-cloud-sync / includes / base / services / s3compatible.php

s3compatible.php in Media Cloud Sync 1.4.1, at includes/base/services/s3compatible.php

1,078 lines 44.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Dudlewebs\WPMCS;
3
4 defined('ABSPATH') || exit;
5
6 // Libraries
7 use Dudlewebs\WPMCS\s3\Aws\S3\S3Client;
8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 use Dudlewebs\WPMCS\s3\Aws\Command;
14 use Exception;
15
16 class S3Compatible {
17 private $assets_url;
18 private $version;
19 private $token;
20
21 protected $config;
22 protected $bucketConfig;
23 protected $settings;
24 protected $credentials;
25 protected $bucket_name;
26 protected $cdnConfig;
27
28 public $service = 's3compatible';
29 public $S3CompatibleClient = false;
30
31 /**
32 * Admin constructor.
33 * @since 1.0.0
34 */
35 public function __construct($credentials = null) {
36 $this->assets_url = WPMCS_ASSETS_URL;
37 $this->version = WPMCS_VERSION;
38 $this->token = WPMCS_TOKEN;
39
40 // Initialize setup
41 $this->init($credentials);
42 }
43
44 /**
45 * Initialise Client
46 *
47 * @param array|null $credentials Optional explicit credentials; falls back to
48 * Utils::get_credentials() when omitted.
49 */
50 public function init($credentials = null) {
51 $this->settings = Utils::get_settings();
52 $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
54 ? $this->credentials['config']
55 : [];
56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
57 ? $this->credentials['bucketConfig']
58 : [];
59 $this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
60 ? $this->bucketConfig['bucket_name']
61 : '';
62 $this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
63 ? $this->credentials['cdn']
64 : [];
65
66 if (
67 isset($this->config['access_key']) && !empty($this->config['access_key']) &&
68 isset($this->config['secret_key']) && !empty($this->config['secret_key']) &&
69 isset($this->config['endpoint']) && !empty($this->config['endpoint'])
70 ) {
71 $this->S3CompatibleClient = new S3Client([
72 'version' => '2006-03-01',
73 'region' => $this->config['region'] ? $this->config['region'] : 'us-east-1',
74 'endpoint' => $this->config['endpoint'], // DigitalOcean Spaces requires a custom endpoint
75 'use_accelerate_endpoint' => isset($this->bucketConfig['transfer_acceleration'])
76 ? $this->bucketConfig['transfer_acceleration'] : false,
77 'use_path_style_endpoint' => true, // DigitalOcean Spaces often requires path-style endpoints
78 'use_aws_shared_config_files' => false,
79 'credentials' => [
80 'key' => $this->config['access_key'],
81 'secret' => $this->config['secret_key'],
82 ],
83 ]);
84 }
85
86 }
87
88
89 /**
90 * Verify Credentials
91 * @since 1.0.0
92 * @return boolean
93 */
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])) {
100 try {
101 $S3CompatibleClient = new S3Client([
102 'version' => '2006-03-01',
103 'region' => $region ? $region : 'us-east-1',
104 'endpoint' => $endpoint,
105 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
106 'use_accelerate_endpoint' => false,
107 'use_aws_shared_config_files' => false,
108 'credentials' => [
109 'key' => $access_key,
110 'secret' => $secret_key,
111 ],
112 ]);
113
114 $result = [
115 'success' => false,
116 'code' => 200,
117 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
118 ];
119
120 try {
121 $S3CompatibleClient->listObjectsV2([
122 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
123 ]);
124
125 // If we reach here, the credentials are valid
126 $result = [
127 'success' => true,
128 'code' => 200,
129 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
130 ];
131 } catch (AwsException $e) {
132 $code = $e->getAwsErrorCode();
133
134 $validErrors = [
135 'AccessDenied',
136 'NoSuchBucket',
137 'AllAccessDisabled',
138 'AuthorizationHeaderMalformed',
139 'PermanentRedirect',
140 'InvalidBucketName',
141 ];
142
143 if (in_array($code, $validErrors)) {
144 // If we reach here, the credentials are valid
145 $result = [
146 'success' => true,
147 'code' => 200,
148 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
149 ];
150 }
151 }
152
153 if($result['success'] == false) {
154 return $result;
155 }
156 $result['buckets_data']['buckets'] = [];
157 $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
158 $result['buckets_data']['status'] = true;
159 return $result;
160 } catch (S3Exception $ex) {
161 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
162 } catch (Exception $ex) {
163 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
164 }
165 }
166 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
167 }
168
169 /**
170 * Verify Bucket
171 * @since 1.0.0
172 * @return boolean
173 */
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]) ) {
182 try {
183 $S3CompatibleClient = new S3Client([
184 'version' => '2006-03-01',
185 'region' => $region ? $region : 'us-east-1',
186 'endpoint' => $endpoint,
187 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
188 'use_accelerate_endpoint' => false,
189 'use_aws_shared_config_files' => false,
190 'credentials' => [
191 'key' => $access_key,
192 'secret' => $secret_key,
193 ],
194 ]);
195
196 //get S3 object
197 $bucket_found = false;
198 try {
199 $S3CompatibleClient->getObject([
200 'Bucket' => $bucket_name,
201 'Key' => $this->token . '_dummy-object-for-bucket-exist-check'
202 ]);
203 $bucket_found = true;
204 } catch (AwsException $e) {
205 $code = $e->getAwsErrorCode();
206 if ($code === 'NoSuchKey') {
207 $bucket_found = true;
208 }
209 }
210
211 if($bucket_found) {
212 return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
213 } else {
214 return array('message' => esc_html__("Bucket choosen does not exist / does not have read permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
215 }
216 }
217 catch (S3Exception $ex) {
218 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
219 } catch (Exception $ex) {
220 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
221 }
222 }
223 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
224 }
225
226 /**
227 * Create Bucket
228 * @since 1.0.0
229 * @return boolean
230 */
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])) {
239 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
240 }
241
242 try {
243 $S3CompatibleClient = new S3Client([
244 'version' => '2006-03-01',
245 'region' => $region ? $region : 'us-east-1',
246 'endpoint' => $endpoint,
247 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
248 'use_accelerate_endpoint' => false,
249 'use_aws_shared_config_files' => false,
250 'credentials' => [
251 'key' => $access_key,
252 'secret' => $secret_key,
253 ],
254 ]);
255
256 // Create Bucket
257 $S3CompatibleClient->createBucket([
258 'Bucket' => $bucket_name,
259 ]);
260
261 return [
262 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
263 'data' => [
264 'Name' => $bucket_name,
265 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
266 ],
267 'code' => 200,
268 'success' => true,
269 ];
270 } catch (AwsException $ex) {
271 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
272 } catch (S3Exception $ex) {
273 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
274 } catch (Exception $ex) {
275 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
276 }
277 }
278
279
280 /**
281 * Check Bucket Write Permission
282 * @since 1.0.0
283 */
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])) {
292 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
293 }
294
295 try {
296 $S3CompatibleClient = new S3Client([
297 'version' => '2006-03-01',
298 'region' => $region ? $region : 'us-east-1',
299 'endpoint' => $endpoint,
300 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
301 'use_accelerate_endpoint' => false,
302 'use_aws_shared_config_files' => false,
303 'credentials' => [
304 'key' => $access_key,
305 'secret' => $secret_key,
306 ],
307 ]);
308
309 $object_key = Utils::get_permission_check_object_key();
310
311
312 // Create a dummy object to check write permission
313 $S3CompatibleClient->putObject([
314 'Bucket' => $bucket_name,
315 'Key' => $object_key,
316 'Body' => 'This is a test object to check write permission.',
317 ]);
318 // Check if the object was created successfully
319 if ($this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
320 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
321 } else {
322 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
323 }
324
325 } catch (AwsException $ex) {
326 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
327 } catch (S3Exception $ex) {
328 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
329 } catch (Exception $ex) {
330 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
331 }
332 }
333
334
335
336 /**
337 * Check Bucket Delete Permission
338 * @since 1.0.0
339 */
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])) {
348 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
349 }
350
351 try {
352 $S3CompatibleClient = new S3Client([
353 'version' => '2006-03-01',
354 'region' => $region ? $region : 'us-east-1',
355 'endpoint' => $endpoint,
356 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
357 'use_accelerate_endpoint' => false,
358 'use_aws_shared_config_files' => false,
359 'credentials' => [
360 'key' => $access_key,
361 'secret' => $secret_key,
362 ],
363 ]);
364
365 $object_key = Utils::get_permission_check_object_key();
366
367 // Create a dummy object to check dlete permission
368 $S3CompatibleClient->deleteObject([
369 'Bucket' => $bucket_name,
370 'Key' => $object_key,
371 ]);
372
373 // Check if the object was created successfully
374 if (!$this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
375 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
376 } else {
377 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
378 }
379
380 } catch (AwsException $ex) {
381 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
382 } catch (S3Exception $ex) {
383 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
384 } catch (Exception $ex) {
385 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
386 }
387 }
388
389
390 /**
391 * Check Bucket Read Permission
392 * @since 1.2.4
393 */
394 public function verifyObjectReadPermission() {
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()];
404 }
405
406 try {
407 $object_key = Utils::get_permission_check_object_key();
408
409 // Check if the object was created successfully
410 if (!$this->exists($object_key)) {
411 // Create a dummy object to check write permission
412 $this->S3CompatibleClient->putObject([
413 'Bucket' => $this->bucket_name,
414 'Key' => $object_key,
415 'Body' => 'This is a test object to check permission.',
416 'ContentType' => 'text/plain',
417 'CacheControl' => 'no-cache, no-store, must-revalidate',
418 ]);
419 }
420
421
422 $url = $this->generate_file_url($object_key);
423 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
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) {
433 $result['status'] = true;
434 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
435 } else if ($status_code === 403) {
436 $result['status'] = false;
437 if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
438 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
439 } else {
440 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
441 }
442 } else if ($status_code === 404) {
443 $result['status'] = false;
444 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
445 } else if ($status_code === 500) {
446 $result['status'] = false;
447 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
448 } else {
449 $result['status'] = false;
450 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
451 }
452
453 $this->deleteSingle($object_key);
454 return [
455 'message' => $result['message'],
456 'code' => 200,
457 'success' => $result['status'],
458 'lastChecked' => $result['lastChecked'],
459 ];
460 } catch (AwsException $ex) {
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']];
463 } catch (S3Exception $ex) {
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']];
466 } catch (Exception $ex) {
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'] ];
469 }
470 }
471
472
473 /**
474 * isConfigured Function To Identify the congfigurations are correct
475 * @since 1.0.0
476 */
477 public function isConfigured(){
478 if ($this->S3CompatibleClient) {
479 try {
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) {
505 return false;
506 } catch (Exception $ex) {
507 return false;
508 }
509 }
510 return false;
511 }
512
513 /**
514 * Make Object Private
515 * @since 1.0.0
516 *
517 */
518 public function toPrivate($key) {
519 if(!$key) return false;
520 if(!$this->S3CompatibleClient) return false;
521 try {
522 $this->S3CompatibleClient->putObjectAcl([
523 'Bucket' => $this->bucket_name,
524 'Key' => $key,
525 'ACL' => 'private'
526 ]);
527 return true;
528 } catch (AwsException $ex) {
529 return false;
530 }
531 }
532
533
534
535 /**
536 * Make Object Public
537 * @since 1.0.0
538 *
539 */
540 public function toPublic($key) {
541 if(!$key) return false;
542 if(!$this->S3CompatibleClient) return false;
543 try {
544 $this->S3CompatibleClient->putObjectAcl([
545 'Bucket' => $this->bucket_name,
546 'Key' => $key,
547 'ACL' => 'public-read'
548 ]);
549 return true;
550 } catch (AwsException $ex) {
551 return false;
552 }
553 }
554
555
556
557 /**
558 * Check the object exist
559 * @since 1.1.8
560 */
561 public function exists($key, $bucket_name = '', $client = null) {
562 if(!$key) return false;
563
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;
577 }
578 }
579
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 /**
633 * Upload Single
634 * @since 1.0.0
635 * @return boolean
636 */
637 public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
638 if (
639 isset($absolute_source_path) && !empty($absolute_source_path) &&
640 isset($relative_source_path) && !empty($relative_source_path)
641 ) {
642 $file_name = wp_basename( $relative_source_path );
643 if ($file_name) {
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 }
666
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 }
674
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 ];
742 }
743 $options = ['state' => $e->getState()];
744 } catch (AwsException $e) {
745 return [
746 'success' => false,
747 'code' => 200,
748 'message' => $e->getMessage()
749 ];
750 } catch (Exception $e) {
751 return [
752 'success' => false,
753 'code' => 200,
754 'message' => $e->getMessage()
755 ];
756 }
757 }
758 }
759
760 /**
761 * Save object to server
762 * @since 1.0.0
763 */
764 public function object_to_server($key, $save_path) {
765 if(!$this->S3CompatibleClient) return false;
766 try {
767 $getObject = $this->S3CompatibleClient->GetObject([
768 'Bucket' => $this->bucket_name,
769 'Key' => $key,
770 'SaveAs' => $save_path
771 ]);
772 if (file_exists($save_path)) {
773 return true;
774 }
775 } catch (AwsException $e) {
776 return false;
777 }
778 return false;
779 }
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 }
798
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 /**
930 * Delete Single
931 * @since 1.0.0
932 * @return boolean
933 */
934 public function deleteSingle($key) {
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 }
943 if (isset($key) && !empty($key)) {
944 try {
945 $this->S3CompatibleClient->deleteObject([
946 'Bucket' => $this->bucket_name,
947 'Key' => $key
948 ]);
949
950 if (!$this->exists($key)) {
951 $result = array(
952 'success' => true,
953 'code' => 200,
954 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
955 );
956 } else {
957 $result = array(
958 'success' => false,
959 'code' => 200,
960 'message' => esc_html__('File not deleted', 'media-cloud-sync')
961 );
962 }
963 } catch (AwsException $e) {
964 $result = array(
965 'success' => false,
966 'code' => 200,
967 'message' => $e->getMessage()
968 );
969 }
970 } else {
971 $result = array(
972 'success' => false,
973 'code' => 200,
974 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
975 );
976 }
977 return $result;
978 }
979
980 /**
981 * get private URL
982 * @since 1.0.0
983 * @return boolean
984 */
985 public function get_private_url($key) {
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 }
994 if (isset($key) && !empty($key)) {
995 try {
996 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
997 'Bucket' => $this->bucket_name,
998 'Key' => $key
999 ]);
1000
1001 $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
1002
1003 $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1004
1005 if ($privateUrl = (string)$request->getUri()) {
1006 $result = array(
1007 'success' => true,
1008 'code' => 200,
1009 'file_url' => $privateUrl,
1010 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
1011 );
1012 } else {
1013 $result = array(
1014 'success' => false,
1015 'code' => 200,
1016 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
1017 );
1018 }
1019 } catch (AwsException $e) {
1020 $result = array(
1021 'success' => false,
1022 'code' => 200,
1023 'message' => $e->getMessage()
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 );
1037 }
1038 } else {
1039 $result = array(
1040 'success' => false,
1041 'code' => 200,
1042 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1043 );
1044 }
1045 return $result;
1046 }
1047
1048 /**
1049 * Generate file URL
1050 */
1051 public function generate_file_url($key){
1052 $domain = $this->get_domain();
1053
1054 return apply_filters('wpmcs_generate_do_file_url',
1055 $domain . '/' . $this->bucket_name . '/' . $key,
1056 $domain,
1057 $this->bucket_name,
1058 $key
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);
1069 }
1070
1071 /**
1072 * Get domain URL
1073 */
1074 public function get_domain($region = '') {
1075 return $this->config['endpoint'];
1076 }
1077
1078 }