PluginProbe
Media Cloud Sync / trunk
Media Cloud Sync vtrunk
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 1.3.0 All 34 releases
media-cloud-sync / includes / base / services / s3compatible.php

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

1,071 lines 44.4 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='') {
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);
645 return $this->execute_upload($absolute_source_path, $upload_path);
646 }
647 return [
648 'success' => false,
649 'code' => 200,
650 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
651 ];
652 }
653 return [
654 'success' => false,
655 'code' => 200,
656 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
657 ];
658 }
659
660 /**
661 * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
662 * @since 1.4.0
663 */
664 public function uploadObjectAtKey($absolute_source_path, $key) {
665 return $this->execute_upload($absolute_source_path, $key);
666 }
667
668 /**
669 * Build an unexecuted ObjectUploader (single PUT or multipart, decided internally by the
670 * SDK, using this plugin's own multipart threshold rather than the SDK's 16MB default).
671 * ACL is stripped via before_* hooks — this plugin's model is bucket-level, not per-object,
672 * and an explicit `ACL: null` still serializes to an empty x-amz-acl header otherwise.
673 * $options is threaded straight into the SDK (e.g. 'state' => UploadState to resume a
674 * previously-failed multipart attempt).
675 * @since 1.4.0
676 */
677 private function build_object_uploader($absolute_source_path, $key, $options = []) {
678 $handle = fopen($absolute_source_path, 'rb');
679 $params = [];
680 $cache_control = Utils::get_cache_control_header();
681 if ($cache_control) {
682 $params['CacheControl'] = $cache_control;
683 }
684 $options += [
685 'mup_threshold' => Schema::getConstant('S3COMPATIBLE_MULTIPART_MIN_FILE_SIZE'),
686 'params' => $params,
687 'before_initiate' => function ($params) { return $this->strip_acl($params); },
688 'before_upload' => function ($params) { return $this->strip_acl($params); },
689 'before_complete' => function ($params) { return $this->strip_acl($params); },
690 ];
691 return new ObjectUploader($this->S3CompatibleClient, $this->bucket_name, $key, $handle, null, $options);
692 }
693
694 // Mutate in place, not a clone — the SDK's before_* hooks call this and discard the
695 // return value, relying on the same Command object being modified.
696 private function strip_acl($params) {
697 if ($params instanceof Command && $params->hasParam('ACL')) {
698 unset($params['ACL']);
699 } elseif (is_array($params) && isset($params['ACL'])) {
700 unset($params['ACL']);
701 }
702 return $params;
703 }
704
705 /**
706 * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
707 * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
708 * state rather than restarting the whole upload — same retry contract uploadSingle()
709 * had before the ObjectUploader swap.
710 * @since 1.4.0
711 */
712 private function execute_upload($absolute_source_path, $key) {
713 $max_attempts = 3;
714 $attempt = 0;
715 $options = [];
716
717 while (true) {
718 $attempt++;
719 try {
720 $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
721 return [
722 'success' => true,
723 'code' => 200,
724 'file_url' => $this->generate_file_url($key),
725 'key' => $key,
726 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
727 ];
728 } catch (MultipartUploadException $e) {
729 if ($attempt >= $max_attempts) {
730 return [
731 'success' => false,
732 'code' => 200,
733 'message' => $e->getMessage()
734 ];
735 }
736 $options = ['state' => $e->getState()];
737 } catch (AwsException $e) {
738 return [
739 'success' => false,
740 'code' => 200,
741 'message' => $e->getMessage()
742 ];
743 } catch (Exception $e) {
744 return [
745 'success' => false,
746 'code' => 200,
747 'message' => $e->getMessage()
748 ];
749 }
750 }
751 }
752
753 /**
754 * Save object to server
755 * @since 1.0.0
756 */
757 public function object_to_server($key, $save_path) {
758 if(!$this->S3CompatibleClient) return false;
759 try {
760 $getObject = $this->S3CompatibleClient->GetObject([
761 'Bucket' => $this->bucket_name,
762 'Key' => $key,
763 'SaveAs' => $save_path
764 ]);
765 if (file_exists($save_path)) {
766 return true;
767 }
768 } catch (AwsException $e) {
769 return false;
770 }
771 return false;
772 }
773
774 /**
775 * Object bytes in memory, no local file — for callers (e.g. zip download) that need
776 * the content itself rather than a copy on the server's filesystem.
777 * @since 1.3.13
778 */
779 public function get_object_content($key) {
780 if(!$this->S3CompatibleClient) return false;
781 try {
782 $result = $this->S3CompatibleClient->GetObject([
783 'Bucket' => $this->bucket_name,
784 'Key' => $key,
785 ]);
786 return (string) $result['Body'];
787 } catch (AwsException $e) {
788 return false;
789 }
790 }
791
792 /**
793 * Deletes the live object, then best-effort purges every historical version too — a
794 * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
795 * versions (and the storage they use) behind at the old key. The live delete happens
796 * unconditionally first: not every S3-compatible endpoint supports ListObjectVersions
797 * (confirmed missing on Cloudflare R2, a live 501 "NotImplemented"), and the object must
798 * still end up gone either way.
799 * @since 1.3.14
800 */
801 public function purge_all_versions($key) {
802 if (!$this->S3CompatibleClient) {
803 return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
804 }
805
806 try {
807 $this->S3CompatibleClient->deleteObject([
808 'Bucket' => $this->bucket_name,
809 'Key' => $key,
810 ]);
811 } catch (AwsException $e) {
812 return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
813 }
814
815 // Best-effort only from here — providers that don't support version listing simply
816 // skip this part; the live object above is already gone regardless.
817 try {
818 $objects = [];
819 $marker = null;
820 do {
821 $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
822 if ($marker) {
823 $args['KeyMarker'] = $marker['key'];
824 $args['VersionIdMarker'] = $marker['version'];
825 }
826 $result = $this->S3CompatibleClient->listObjectVersions($args);
827 foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
828 if (($version['Key'] ?? null) === $key) {
829 $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
830 }
831 }
832 $marker = !empty($result['IsTruncated'])
833 ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
834 : null;
835 } while ($marker);
836
837 foreach (array_chunk($objects, 1000) as $chunk) {
838 $this->S3CompatibleClient->deleteObjects([
839 'Bucket' => $this->bucket_name,
840 'Delete' => ['Objects' => $chunk],
841 ]);
842 }
843 } catch (AwsException $e) {
844 // Version history cleanup unsupported/failed — not fatal, live object is gone.
845 }
846
847 return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
848 }
849
850
851 /**
852 * Copy to new path
853 * @since 1.3.4
854 */
855 // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
856 // exists() HEAD requests — each one is a full network round-trip, and with move/copy
857 // processing keys sequentially, three extra round-trips per file adds up fast on a
858 // folder with many files. copyObject() itself throws (caught below) if the source is
859 // missing or the copy otherwise fails, so nothing is lost by not checking first.
860 public function copy_to_new_path($key, $new_path) {
861 if (!$this->S3CompatibleClient) {
862 return [
863 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
864 'code' => 200,
865 'success' => false
866 ];
867 }
868 try {
869 $this->S3CompatibleClient->copyObject([
870 'Bucket' => $this->bucket_name,
871 'CopySource' => "{$this->bucket_name}/{$key}",
872 'Key' => $new_path,
873 'MetadataDirective' => 'COPY',
874 ]);
875 return [
876 'success' => true,
877 'code' => 200,
878 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
879 ];
880 } catch (AwsException $e) {
881 return [
882 'success' => false,
883 'code' => 200,
884 'message' => $e->getMessage()
885 ];
886 }
887 }
888
889 // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
890 // access there too (and the same endpoint), so callers should fall back to download+upload
891 // on failure.
892 public function copy_to_bucket($key, $new_key, $dest_bucket) {
893 if (!$this->S3CompatibleClient) {
894 return [
895 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
896 'code' => 200,
897 'success' => false
898 ];
899 }
900 try {
901 $this->S3CompatibleClient->copyObject([
902 'Bucket' => $dest_bucket,
903 'CopySource' => "{$this->bucket_name}/{$key}",
904 'Key' => $new_key,
905 'MetadataDirective' => 'COPY',
906 ]);
907 return [
908 'success' => true,
909 'code' => 200,
910 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
911 ];
912 } catch (AwsException $e) {
913 return [
914 'success' => false,
915 'code' => 200,
916 'message' => $e->getMessage()
917 ];
918 }
919 }
920
921
922 /**
923 * Delete Single
924 * @since 1.0.0
925 * @return boolean
926 */
927 public function deleteSingle($key) {
928 $result = array();
929 if (!$this->S3CompatibleClient) {
930 return array(
931 'success' => false,
932 'code' => 200,
933 'message' => esc_html__('Client not configured', 'media-cloud-sync')
934 );
935 }
936 if (isset($key) && !empty($key)) {
937 try {
938 $this->S3CompatibleClient->deleteObject([
939 'Bucket' => $this->bucket_name,
940 'Key' => $key
941 ]);
942
943 if (!$this->exists($key)) {
944 $result = array(
945 'success' => true,
946 'code' => 200,
947 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
948 );
949 } else {
950 $result = array(
951 'success' => false,
952 'code' => 200,
953 'message' => esc_html__('File not deleted', 'media-cloud-sync')
954 );
955 }
956 } catch (AwsException $e) {
957 $result = array(
958 'success' => false,
959 'code' => 200,
960 'message' => $e->getMessage()
961 );
962 }
963 } else {
964 $result = array(
965 'success' => false,
966 'code' => 200,
967 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
968 );
969 }
970 return $result;
971 }
972
973 /**
974 * get private URL
975 * @since 1.0.0
976 * @return boolean
977 */
978 public function get_private_url($key) {
979 $result = array();
980 if (!$this->S3CompatibleClient) {
981 return array(
982 'success' => false,
983 'code' => 200,
984 'message' => esc_html__('Client not configured', 'media-cloud-sync')
985 );
986 }
987 if (isset($key) && !empty($key)) {
988 try {
989 $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
990 'Bucket' => $this->bucket_name,
991 'Key' => $key
992 ]);
993
994 $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
995
996 $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
997
998 if ($privateUrl = (string)$request->getUri()) {
999 $result = array(
1000 'success' => true,
1001 'code' => 200,
1002 'file_url' => $privateUrl,
1003 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
1004 );
1005 } else {
1006 $result = array(
1007 'success' => false,
1008 'code' => 200,
1009 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
1010 );
1011 }
1012 } catch (AwsException $e) {
1013 $result = array(
1014 'success' => false,
1015 'code' => 200,
1016 'message' => $e->getMessage()
1017 );
1018 } catch (S3Exception $e) {
1019 $result = array(
1020 'success' => false,
1021 'code' => 200,
1022 'message' => $e->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync')
1023 );
1024 } catch (Exception $e) {
1025 $result = array(
1026 'success' => false,
1027 'code' => 200,
1028 'message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync')
1029 );
1030 }
1031 } else {
1032 $result = array(
1033 'success' => false,
1034 'code' => 200,
1035 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1036 );
1037 }
1038 return $result;
1039 }
1040
1041 /**
1042 * Generate file URL
1043 */
1044 public function generate_file_url($key){
1045 $domain = $this->get_domain();
1046
1047 return apply_filters('wpmcs_generate_do_file_url',
1048 $domain . '/' . $this->bucket_name . '/' . $key,
1049 $domain,
1050 $this->bucket_name,
1051 $key
1052 );
1053 }
1054
1055 /**
1056 * Is Provider URL
1057 * @since 1.3.6
1058 */
1059 public function is_provider_url($url) {
1060 $domain = $this->get_domain();
1061 return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
1062 }
1063
1064 /**
1065 * Get domain URL
1066 */
1067 public function get_domain($region = '') {
1068 return $this->config['endpoint'];
1069 }
1070
1071 }