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 / cloudflare-r2.php

cloudflare-r2.php in Media Cloud Sync 1.4.1, at includes/base/services/cloudflare-r2.php

1,074 lines 45.5 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\Command;
13 use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
14 use Exception;
15
16 class CloudflareR2 {
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 = 'cloudflareR2';
29 public $cloudflareClient = 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 // Cloudflare R2 needs Account ID + access/secret
67 if (
68 isset($this->config['account_id']) && !empty($this->config['account_id']) &&
69 isset($this->config['access_key']) && !empty($this->config['access_key']) &&
70 isset($this->config['secret_key']) && !empty($this->config['secret_key'])
71 ) {
72 $this->cloudflareClient = new S3Client([
73 'version' => '2006-03-01',
74 'region' => 'auto', // Cloudflare R2 uses 'auto' region
75 'endpoint' => $this->get_domain(),
76 'signature_version' => 'v4',
77 'use_accelerate_endpoint' => false,
78 'use_path_style_endpoint' => true,
79 'use_aws_shared_config_files' => false,
80 'credentials' => [
81 'key' => $this->config['access_key'],
82 'secret' => $this->config['secret_key'],
83 ],
84 ]);
85 }
86
87 }
88
89
90 /**
91 * Verify Credentials
92 * @since 1.0.0
93 * @return boolean
94 */
95 public function verifyCredentials( $config = [] ) {
96 $account_id = isset($config['account_id']) ? $config['account_id'] : false;
97 $access_key = isset($config['access_key']) ? $config['access_key'] : false;
98 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : false;
99 $region = isset($config['region']) ? $config['region'] : '';
100
101 if (!Service::has_missing_fields([$account_id, $access_key, $secret_key])) {
102 try {
103 $cloudflareClient = new S3Client([
104 'version' => '2006-03-01',
105 'region' => 'auto',
106 'endpoint' => $this->get_domain($account_id, $region),
107 'signature_version' => 'v4',
108 'use_path_style_endpoint' => true,
109 'use_accelerate_endpoint' => false,
110 'use_aws_shared_config_files' => false,
111 'credentials' => [
112 'key' => $access_key,
113 'secret' => $secret_key,
114 ],
115 ]);
116
117 $result = [
118 'success' => false,
119 'code' => 200,
120 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
121 ];
122
123 try {
124 $cloudflareClient->listObjectsV2([
125 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
126 ]);
127
128 // If we reach here, the credentials are valid
129 $result = [
130 'success' => true,
131 'code' => 200,
132 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
133 ];
134 } catch (AwsException $e) {
135 $code = $e->getAwsErrorCode();
136
137 $validErrors = [
138 'AccessDenied',
139 'NoSuchBucket',
140 'AllAccessDisabled',
141 'AuthorizationHeaderMalformed',
142 'PermanentRedirect',
143 'InvalidBucketName',
144 ];
145
146 if (in_array($code, $validErrors)) {
147 // If we reach here, the credentials are valid
148 $result = [
149 'success' => true,
150 'code' => 200,
151 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
152 ];
153 }
154 }
155
156 if($result['success'] == false) {
157 return $result;
158 }
159
160 try {
161 $buckets = $cloudflareClient->listBuckets();
162 $newBucketFormat = [];
163 if(isset($buckets['Buckets']) && !empty($buckets['Buckets'])){
164 foreach($buckets['Buckets'] as $bucket) {
165 if(isset($bucket['Name'])) {
166 $newBucketFormat[] = ['Name' => $bucket['Name'], 'CreationDate' => $bucket['CreationDate'] ?? ''];
167 }
168 }
169 }
170 $result['buckets_data']['buckets'] = $newBucketFormat;
171 $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
172 $result['buckets_data']['status'] = true;
173 } catch (S3Exception $e) {
174 $result ['buckets_data']['buckets'] = [];
175 $result ['buckets_data']['message'] = esc_html__('Unable to list buckets, Please check the bucket listing permission', 'media-cloud-sync');
176 $result ['buckets_data']['status'] = false;
177 } catch (Exception $e) {
178 $result ['buckets_data']['buckets'] = [];
179 $result ['buckets_data']['message'] = esc_html__('Unable to list buckets, Please check the bucket listing permission', 'media-cloud-sync');
180 $result ['buckets_data']['status'] = false;
181 }
182 return $result;
183 } catch (S3Exception $ex) {
184 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
185 } catch (Exception $ex) {
186 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
187 }
188 }
189 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
190 }
191
192 /**
193 * Verify Bucket
194 * @since 1.0.0
195 * @return boolean
196 */
197 public function verifyBucketExist( $config = [], $bucketConfig = [] ) {
198 $account_id = isset($config['account_id']) ? $config['account_id'] : false;
199 $access_key = isset($config['access_key']) ? $config['access_key'] : false;
200 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : false;
201 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : false;
202 $region = isset($config['region']) ? $config['region'] : '';
203
204 if ( Service::has_missing_fields([$account_id, $access_key, $secret_key, $bucket_name]) ) {
205 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
206 }
207
208 try {
209 $cloudflareClient = new S3Client([
210 'version' => '2006-03-01',
211 'region' => 'auto',
212 'endpoint' => $this->get_domain($account_id, $region),
213 'signature_version' => 'v4',
214 'use_path_style_endpoint' => true,
215 'use_accelerate_endpoint' => false,
216 'use_aws_shared_config_files' => false,
217 'credentials' => [
218 'key' => $access_key,
219 'secret' => $secret_key,
220 ],
221 ]);
222
223 //get S3 object
224 $bucket_found = false;
225 try {
226 $cloudflareClient->getObject([
227 'Bucket' => $bucket_name,
228 'Key' => $this->token . '_dummy-object-for-bucket-exist-check'
229 ]);
230 $bucket_found = true;
231 } catch (AwsException $e) {
232 $code = $e->getAwsErrorCode();
233 if ($code === 'NoSuchKey') {
234 $bucket_found = true;
235 }
236 }
237
238 if($bucket_found) {
239 return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
240 } else {
241 return array('message' => esc_html__("Bucket choosen does not exist or you don't have permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
242 }
243 } catch (S3Exception $ex) {
244 return array('message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
245 } catch (Exception $ex) {
246 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
247 }
248 }
249
250 /**
251 * Create Bucket
252 * @since 1.0.0
253 */
254 public function createBucket( $config = [], $bucketConfig = [] ) {
255 $account_id = isset($config['account_id']) ? $config['account_id'] : '';
256 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
257 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
258 $region = isset($config['region']) ? $config['region'] : '';
259 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
260
261 if ( Service::has_missing_fields([$account_id, $access_key, $secret_key, $bucket_name]) ) {
262 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
263 }
264
265 try {
266 $cloudflareClient = new S3Client([
267 'version' => '2006-03-01',
268 'region' => 'auto',
269 'endpoint' => $this->get_domain($account_id, $region),
270 'signature_version' => 'v4',
271 'use_path_style_endpoint' => true,
272 'use_accelerate_endpoint' => false,
273 'use_aws_shared_config_files' => false,
274 'credentials' => [
275 'key' => $access_key,
276 'secret' => $secret_key,
277 ],
278 ]);
279
280 // Create bucket - R2 supports CreateBucket via the S3 API
281 $cloudflareClient->createBucket(['Bucket' => $bucket_name]);
282
283 return [
284 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
285 'data' => [
286 'Name' => $bucket_name,
287 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
288 ],
289 'code' => 200,
290 'success' => true,
291 ];
292 } catch (AwsException $ex) {
293 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
294 } catch (S3Exception $ex) {
295 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
296 } catch (Exception $ex) {
297 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
298 }
299 }
300
301 /**
302 * Check Bucket Write Permission
303 * @since 1.0.0
304 */
305 public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
306 $account_id = isset($config['account_id']) ? $config['account_id'] : '';
307 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
308 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
309 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
310 $region = isset($config['region']) ? $config['region'] : '';
311
312 if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $account_id])) {
313 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
314 }
315
316 try {
317 $cloudflareClient = new S3Client([
318 'version' => '2006-03-01',
319 'region' => 'auto',
320 'endpoint' => $this->get_domain($account_id, $region),
321 'signature_version' => 'v4',
322 'use_path_style_endpoint' => true,
323 'use_accelerate_endpoint' => false,
324 'use_aws_shared_config_files' => false,
325 'credentials' => [
326 'key' => $access_key,
327 'secret' => $secret_key,
328 ],
329 ]);
330
331 $object_key = Utils::get_permission_check_object_key();
332
333 // create an empty object to test write permission
334 $cloudflareClient->putObject([
335 'Bucket' => $bucket_name,
336 'Key' => $object_key,
337 'Body' => 'This is a test object to check write permission.',
338 ]);
339
340 // Check if the object was created successfully
341 if ($this->exists( $object_key, $bucket_name, $cloudflareClient )) {
342 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
343 } else {
344 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
345 }
346
347 } catch (AwsException $ex) {
348 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
349 } catch (S3Exception $ex) {
350 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
351 } catch (Exception $ex) {
352 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
353 }
354 }
355
356 /**
357 * Check Bucket Delete Permission
358 * @since 1.0.0
359 */
360 public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
361 $account_id = isset($config['account_id']) ? $config['account_id'] : '';
362 $access_key = isset($config['access_key']) ? $config['access_key'] : '';
363 $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
364 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
365 $region = isset($config['region']) ? $config['region'] : '';
366
367 if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $account_id])) {
368 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
369 }
370
371 try {
372 $cloudflareClient = new S3Client([
373 'version' => '2006-03-01',
374 'region' => 'auto',
375 'endpoint' => $this->get_domain($account_id, $region),
376 'signature_version' => 'v4',
377 'use_path_style_endpoint' => true,
378 'use_accelerate_endpoint' => false,
379 'use_aws_shared_config_files' => false,
380 'credentials' => [
381 'key' => $access_key,
382 'secret' => $secret_key,
383 ],
384 ]);
385
386 $object_key = Utils::get_permission_check_object_key();
387
388 // Create a dummy object to check delete permission
389 $cloudflareClient->deleteObject([
390 'Bucket' => $bucket_name,
391 'Key' => $object_key,
392 ]);
393
394 // Check if the object was created successfully
395 if (!$this->exists($object_key, $bucket_name, $cloudflareClient)) {
396 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
397 } else {
398 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
399 }
400
401 } catch (AwsException $ex) {
402 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
403 } catch (S3Exception $ex) {
404 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
405 } catch (Exception $ex) {
406 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
407 }
408 }
409
410 /**
411 * Check Bucket Read Permission
412 * @since 1.2.4
413 */
414 public function verifyObjectReadPermission() {
415 $result = [
416 'status' => false,
417 'message' => '',
418 'lastChecked' => time(),
419 ];
420
421 if (Service::has_missing_fields([$this->cloudflareClient, $this->bucket_name])) {
422 $result['message'] = esc_html__('Invalid Request', 'media-cloud-sync');
423 return ['message' => esc_html__('Invalid Request', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
424 }
425
426 try {
427 $object_key = Utils::get_permission_check_object_key();
428
429 // Check if the object was created successfully
430 if (!$this->exists($object_key)) {
431 // Create a dummy object to check write permission
432 $this->cloudflareClient->putObject([
433 'Bucket' => $this->bucket_name,
434 'Key' => $object_key,
435 'Body' => 'This is a test object to check permission.',
436 'ContentType' => 'text/plain',
437 'CacheControl' => 'no-cache, no-store, must-revalidate',
438 ]);
439 }
440
441 $url = $this->generate_file_url($object_key);
442 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
443 // Never trust a cached response for this fixed, predictable URL — a stale cached
444 // error would otherwise keep failing the check long after real access is fine.
445 $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
446 $headers = @get_headers($cdn_url, false, $no_cache_context);
447 $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
448 ? (int) $matches[1]
449 : 0;
450
451 if ($status_code === 200) {
452 $result['status'] = true;
453 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
454 } else if ($status_code === 403) {
455 $result['status'] = false;
456 if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
457 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
458 } else {
459 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
460 }
461 } else if ($status_code === 404) {
462 $result['status'] = false;
463 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
464 } else if ($status_code === 500) {
465 $result['status'] = false;
466 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
467 } else {
468 $result['status'] = false;
469 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
470 }
471
472 $this->deleteSingle($object_key);
473 return [
474 'message' => $result['message'],
475 'code' => 200,
476 'success' => $result['status'],
477 'lastChecked' => $result['lastChecked'],
478 ];
479 } catch (AwsException $ex) {
480 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
481 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false , 'lastChecked' => time()];
482 } catch (S3Exception $ex) {
483 $result['message'] = $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
484 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false , 'lastChecked' => time()];
485 } catch (Exception $ex) {
486 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
487 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false , 'lastChecked' => time()];
488 }
489 }
490
491 /**
492 * isConfigured Function To Identify the configurations are correct
493 * @since 1.0.0
494 */
495 public function isConfigured(){
496 if ($this->cloudflareClient) {
497 try {
498 $this->cloudflareClient->listObjectsV2([
499 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
500 ]);
501
502 // If we reach here, the credentials are valid
503 return true;
504 } catch (AwsException $ex) {
505 $code = $ex->getAwsErrorCode();
506
507 $validErrors = [
508 'AccessDenied',
509 'NoSuchBucket',
510 'AllAccessDisabled',
511 'AuthorizationHeaderMalformed',
512 'PermanentRedirect',
513 'InvalidBucketName'
514 ];
515
516 if (in_array($code, $validErrors)) {
517 // If we reach here, the credentials are valid
518 return true;
519 } else {
520 return false;
521 }
522 } catch (S3Exception $ex) {
523 return false;
524 } catch (Exception $ex) {
525 return false;
526 }
527 }
528 return false;
529 }
530
531 /**
532 * Check the object exists
533 * @since 1.1.8
534 */
535 public function exists($key, $bucket_name = '', $client = null) {
536 if (!$key) return false;
537
538 try {
539 $client = $client ?? $this->cloudflareClient;
540 $client->headObject([
541 'Bucket' => $bucket_name ? $bucket_name : $this->bucket_name,
542 'Key' => $key,
543 ]);
544 return true;
545 } catch (AwsException $ex) {
546 return false;
547 } catch (S3Exception $ex) {
548 return false;
549 } catch (Exception $ex) {
550 return false;
551 }
552 }
553
554 /**
555 * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
556 * @since 1.3.13
557 */
558 public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
559 if (!$this->cloudflareClient) {
560 return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
561 }
562 try {
563 $params = ['Bucket' => $this->bucket_name, 'MaxKeys' => $maxKeys];
564 if (!empty($delimiter)) {
565 $params['Delimiter'] = $delimiter;
566 }
567 if (!empty($prefix)) {
568 $params['Prefix'] = $prefix;
569 }
570 if (!empty($continuationToken)) {
571 $params['ContinuationToken'] = $continuationToken;
572 }
573
574 $result = $this->cloudflareClient->listObjectsV2($params);
575 $folders = [];
576 foreach (($result['CommonPrefixes'] ?? []) as $common) {
577 $folders[] = $common['Prefix'];
578 }
579 $objects = [];
580 foreach (($result['Contents'] ?? []) as $object) {
581 if ($object['Key'] === $prefix) {
582 continue; // the folder placeholder object itself, not a file
583 }
584 $objects[] = [
585 'key' => $object['Key'],
586 'size' => (int) $object['Size'],
587 'last_modified' => $object['LastModified'] ? $object['LastModified']->format(DATE_ATOM) : '',
588 ];
589 }
590
591 return [
592 'success' => true,
593 'code' => 200,
594 'message' => '',
595 'folders' => $folders,
596 'objects' => $objects,
597 'next_token' => !empty($result['IsTruncated']) ? ($result['NextContinuationToken'] ?? null) : null,
598 ];
599 } catch (AwsException $e) {
600 return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
601 } catch (S3Exception $e) {
602 return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
603 } catch (Exception $e) {
604 return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
605 }
606 }
607
608 /**
609 * Upload Single
610 * @since 1.0.0
611 */
612 public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
613 if (
614 isset($absolute_source_path) && !empty($absolute_source_path) &&
615 isset($relative_source_path) && !empty($relative_source_path)
616 ) {
617 $file_name = wp_basename( $relative_source_path );
618 if ($file_name) {
619 $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
620 if ($upload_path === false) {
621 return [
622 'success' => false,
623 'code' => 200,
624 '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')
625 ];
626 }
627 return $this->execute_upload($absolute_source_path, $upload_path);
628 }
629 }
630
631 return [
632 'success' => false,
633 'code' => 200,
634 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
635 ];
636 }
637
638 /**
639 * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
640 * @since 1.4.0
641 */
642 public function uploadObjectAtKey($absolute_source_path, $key) {
643 return $this->execute_upload($absolute_source_path, $key);
644 }
645
646 /**
647 * Build an unexecuted ObjectUploader for the given local file and destination key, using
648 * this plugin's own multipart threshold rather than the SDK's 16MB default. $acl is
649 * explicitly null — R2 doesn't support S3-style ACLs — and the before_initiate/
650 * before_upload/before_complete hooks strip it via strip_acl(), since ObjectUploader's
651 * multipart delegation still threads $options through to MultipartUploader.
652 * @since 1.4.0
653 */
654 private function build_object_uploader($absolute_source_path, $key, $options = []) {
655 $handle = fopen($absolute_source_path, 'rb');
656 $params = [];
657 $cache_control = Utils::get_cache_control_header();
658 if ($cache_control) {
659 $params['CacheControl'] = $cache_control;
660 }
661 $options += [
662 'mup_threshold' => Schema::getConstant('CLOUDFLARE_R2_MULTIPART_MIN_FILE_SIZE'),
663 'params' => $params,
664 'before_initiate' => function ($params) {
665 return $this->strip_acl($params);
666 },
667 'before_upload' => function ($params) {
668 return $this->strip_acl($params);
669 },
670 'before_complete' => function ($params) {
671 return $this->strip_acl($params);
672 },
673 ];
674 return new ObjectUploader($this->cloudflareClient, $this->bucket_name, $key, $handle, null, $options);
675 }
676
677 /**
678 * Run an ObjectUploader synchronously and normalize the result shape. Retries up to
679 * 3 attempts on MultipartUploadException, resuming from the failed attempt's saved
680 * state rather than restarting the whole upload — same retry contract uploadSingle()
681 * had before the ObjectUploader swap.
682 * @since 1.4.0
683 */
684 private function execute_upload($absolute_source_path, $key) {
685 $max_attempts = 3;
686 $attempt = 0;
687 $options = [];
688
689 while (true) {
690 $attempt++;
691 try {
692 $this->build_object_uploader($absolute_source_path, $key, $options)->upload();
693 return [
694 'success' => true,
695 'code' => 200,
696 'file_url' => $this->generate_file_url($key),
697 'key' => $key,
698 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
699 ];
700 } catch (MultipartUploadException $e) {
701 if ($attempt >= $max_attempts) {
702 return [
703 'success' => false,
704 'code' => 200,
705 'message' => $e->getMessage()
706 ];
707 }
708 $options = ['state' => $e->getState()];
709 } catch (AwsException $e) {
710 return [
711 'success' => false,
712 'code' => 200,
713 'message' => $e->getMessage()
714 ];
715 } catch (Exception $e) {
716 return [
717 'success' => false,
718 'code' => 200,
719 'message' => $e->getMessage()
720 ];
721 }
722 }
723 }
724
725 /**
726 * Exclude 'ACL' parameter from given params.
727 *
728 * @param Command|array $params Command or array of parameters.
729 *
730 * @return Command|array Modified params.
731 */
732 private function strip_acl($params) {
733 if ($params instanceof Command) {
734 // Mutate in place, not a clone — the SDK's before_* hooks (both here and in
735 // MultipartUploader) call this and discard the return value, relying on the
736 // same Command object being modified, not a modified copy being returned.
737 if ($params->hasParam('ACL')) {
738 unset($params['ACL']);
739 }
740 } else if (is_array($params)) {
741 if (isset($params['ACL'])) {
742 unset($params['ACL']);
743 }
744 }
745 return $params;
746 }
747
748 /**
749 * Save object to server
750 * @since 1.0.0
751 */
752 public function object_to_server($key, $save_path) {
753 if(!$this->cloudflareClient) return false;
754 try {
755 $getObject = $this->cloudflareClient->getObject([
756 'Bucket' => $this->bucket_name,
757 'Key' => $key,
758 'SaveAs' => $save_path
759 ]);
760 if (file_exists($save_path)) {
761 return true;
762 }
763 } catch (AwsException $e) {
764 return false;
765 }
766 return false;
767 }
768
769 /**
770 * Object bytes in memory, no local file — for callers (e.g. zip download) that need
771 * the content itself rather than a copy on the server's filesystem.
772 * @since 1.3.13
773 */
774 public function get_object_content($key) {
775 if(!$this->cloudflareClient) return false;
776 try {
777 $result = $this->cloudflareClient->getObject([
778 'Bucket' => $this->bucket_name,
779 'Key' => $key,
780 ]);
781 return (string) $result['Body'];
782 } catch (AwsException $e) {
783 return false;
784 }
785 }
786
787 /**
788 * Deletes the live object, then best-effort purges every historical version too — a
789 * plain deleteSingle() on a versioned bucket only adds a delete marker, leaving prior
790 * versions (and the storage they use) behind at the old key. The live delete happens
791 * unconditionally first: some providers (Cloudflare R2 itself doesn't support
792 * ListObjectVersions at all — confirmed via a live 501 "NotImplemented" response) can't
793 * do the version-history part, and the object must still end up gone either way.
794 * @since 1.3.14
795 */
796 public function purge_all_versions($key) {
797 if (!$this->cloudflareClient) {
798 return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
799 }
800
801 try {
802 $this->cloudflareClient->deleteObject([
803 'Bucket' => $this->bucket_name,
804 'Key' => $key,
805 ]);
806 } catch (AwsException $e) {
807 return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
808 }
809
810 // Best-effort only from here — providers that don't support version listing (R2)
811 // simply skip this part; the live object above is already gone regardless.
812 try {
813 $objects = [];
814 $marker = null;
815 do {
816 $args = ['Bucket' => $this->bucket_name, 'Prefix' => $key];
817 if ($marker) {
818 $args['KeyMarker'] = $marker['key'];
819 $args['VersionIdMarker'] = $marker['version'];
820 }
821 $result = $this->cloudflareClient->listObjectVersions($args);
822 foreach (array_merge($result['Versions'] ?? [], $result['DeleteMarkers'] ?? []) as $version) {
823 if (($version['Key'] ?? null) === $key) {
824 $objects[] = ['Key' => $key, 'VersionId' => $version['VersionId']];
825 }
826 }
827 $marker = !empty($result['IsTruncated'])
828 ? ['key' => $result['NextKeyMarker'], 'version' => $result['NextVersionIdMarker']]
829 : null;
830 } while ($marker);
831
832 foreach (array_chunk($objects, 1000) as $chunk) {
833 $this->cloudflareClient->deleteObjects([
834 'Bucket' => $this->bucket_name,
835 'Delete' => ['Objects' => $chunk],
836 ]);
837 }
838 } catch (AwsException $e) {
839 // Version history cleanup unsupported/failed — not fatal, live object is gone.
840 }
841
842 return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
843 }
844
845 /**
846 * Copy to new path
847 * @since 1.3.4
848 */
849 // Trusts copyObject()'s own success/failure rather than pre/post-verifying with extra
850 // exists() HEAD requests — each one is a full network round-trip, and with move/copy
851 // processing keys sequentially, three extra round-trips per file adds up fast on a
852 // folder with many files. copyObject() itself throws (caught below) if the source is
853 // missing or the copy otherwise fails, so nothing is lost by not checking first.
854 public function copy_to_new_path($key, $new_path) {
855 if (!$this->cloudflareClient) {
856 return [
857 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
858 'code' => 200,
859 'success' => false
860 ];
861 }
862 try {
863 $this->cloudflareClient->copyObject([
864 'Bucket' => $this->bucket_name,
865 'CopySource' => "{$this->bucket_name}/{$key}",
866 'Key' => $new_path,
867 'MetadataDirective' => 'COPY',
868 ]);
869 return [
870 'success' => true,
871 'code' => 200,
872 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
873 ];
874 } catch (AwsException $e) {
875 return [
876 'success' => false,
877 'code' => 200,
878 'message' => $e->getMessage()
879 ];
880 }
881 }
882
883 // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
884 // access there too, so callers should fall back to download+upload on failure.
885 public function copy_to_bucket($key, $new_key, $dest_bucket) {
886 if (!$this->cloudflareClient) {
887 return [
888 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
889 'code' => 200,
890 'success' => false
891 ];
892 }
893 try {
894 $this->cloudflareClient->copyObject([
895 'Bucket' => $dest_bucket,
896 'CopySource' => "{$this->bucket_name}/{$key}",
897 'Key' => $new_key,
898 'MetadataDirective' => 'COPY',
899 ]);
900 return [
901 'success' => true,
902 'code' => 200,
903 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
904 ];
905 } catch (AwsException $e) {
906 return [
907 'success' => false,
908 'code' => 200,
909 'message' => $e->getMessage()
910 ];
911 }
912 }
913
914 /**
915 * Delete Single
916 * @since 1.0.0
917 */
918 public function deleteSingle($key) {
919 $result = array();
920 if (!$this->cloudflareClient) {
921 return array(
922 'success' => false,
923 'code' => 200,
924 'message' => esc_html__('Client not configured', 'media-cloud-sync')
925 );
926 }
927 if (isset($key) && !empty($key)) {
928 try {
929 $this->cloudflareClient->deleteObject([
930 'Bucket' => $this->bucket_name,
931 'Key' => $key
932 ]);
933
934 if (!$this->exists($key)) {
935 $result = array(
936 'success' => true,
937 'code' => 200,
938 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
939 );
940 } else {
941 $result = array(
942 'success' => false,
943 'code' => 200,
944 'message' => esc_html__('File not deleted', 'media-cloud-sync')
945 );
946 }
947 } catch (AwsException $e) {
948 $result = array(
949 'success' => false,
950 'code' => 200,
951 'message' => $e->getMessage()
952 );
953 }
954 } else {
955 $result = array(
956 'success' => false,
957 'code' => 200,
958 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
959 );
960 }
961 return $result;
962 }
963
964 /**
965 * get private URL (presigned)
966 * @since 1.0.0
967 */
968 public function get_private_url($key) {
969 $result = array();
970 if (!$this->cloudflareClient) {
971 return array(
972 'success' => false,
973 'code' => 200,
974 'message' => esc_html__('Client not configured', 'media-cloud-sync')
975 );
976 }
977 if (isset($key) && !empty($key)) {
978 try {
979 $cmd = $this->cloudflareClient->getCommand('GetObject', [
980 'Bucket' => $this->bucket_name,
981 'Key' => $key
982 ]);
983
984 $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
985
986 $request = $this->cloudflareClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
987
988 if ($privateUrl = (string)$request->getUri()) {
989 $result = array(
990 'success' => true,
991 'code' => 200,
992 'file_url' => $privateUrl,
993 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
994 );
995 } else {
996 $result = array(
997 'success' => false,
998 'code' => 200,
999 'message' => esc_html__('Error getting Private URL', 'media-cloud-sync')
1000 );
1001 }
1002 } catch (AwsException $e) {
1003 $result = array(
1004 'success' => false,
1005 'code' => 200,
1006 'message' => $e->getMessage()
1007 );
1008 } catch (S3Exception $e) {
1009 $result = array(
1010 'success' => false,
1011 'code' => 200,
1012 'message' => $e->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync')
1013 );
1014 } catch (Exception $e) {
1015 $result = array(
1016 'success' => false,
1017 'code' => 200,
1018 'message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync')
1019 );
1020 }
1021 } else {
1022 $result = array(
1023 'success' => false,
1024 'code' => 200,
1025 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
1026 );
1027 }
1028 return $result;
1029 }
1030
1031 /**
1032 * Generate file URL
1033 */
1034 public function generate_file_url($key){
1035 $domain = $this->get_domain();
1036
1037 return apply_filters('wpmcs_generate_do_file_url',
1038 $domain . '/' . $this->bucket_name . '/' . $key,
1039 $domain,
1040 $this->bucket_name,
1041 $key
1042 );
1043 }
1044
1045 /**
1046 * Is provider URL
1047 * @since 1.3.6
1048 */
1049 public function is_provider_url($url) {
1050 $domain = $this->get_domain();
1051 return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
1052 }
1053
1054 /**
1055 * Get domain URL
1056 * @since 1.0.0
1057 * @return string
1058 */
1059 public function get_domain($account_id = '', $region = 'default') {
1060 if (empty($account_id)) {
1061 $account_id = isset($this->config['account_id']) ? $this->config['account_id'] : '';
1062 }
1063 if (!empty($account_id)) {
1064 $region = ($region === 'default') && isset($this->config['region']) ? $this->config['region'] : $region;
1065 if (!empty($region)) {
1066 return "https://{$account_id}.{$region}.r2.cloudflarestorage.com";
1067 }
1068 return "https://{$account_id}.r2.cloudflarestorage.com";
1069 }
1070 // fallback
1071 return "https://r2.cloudflarestorage.com";
1072 }
1073 }
1074