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
← All changes | includes/base/services/s3compatible.php +570 -226 1.2.21.4.1 View file →
@@ -8,8 +8,10 @@
8 8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 +use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 +use Dudlewebs\WPMCS\s3\Aws\Command;
12 14 use Exception;
13 15
14 16 class S3Compatible {
15 17 private $assets_url;
@@ -20,31 +22,35 @@
20 22 protected $bucketConfig;
21 23 protected $settings;
22 24 protected $credentials;
23 25 protected $bucket_name;
26 + protected $cdnConfig;
24 27
25 - public $service = 's3compatible';
26 - public $S3Client = false;
28 + public $service = 's3compatible';
29 + public $S3CompatibleClient = false;
27 30
28 31 /**
29 32 * Admin constructor.
30 33 * @since 1.0.0
31 34 */
32 - public function __construct() {
35 + public function __construct($credentials = null) {
33 36 $this->assets_url = WPMCS_ASSETS_URL;
34 37 $this->version = WPMCS_VERSION;
35 38 $this->token = WPMCS_TOKEN;
36 39
37 40 // Initialize setup
38 - $this->init();
41 + $this->init($credentials);
39 42 }
40 43
41 44 /**
42 45 * Initialise Client
46 + *
47 + * @param array|null $credentials Optional explicit credentials; falls back to
48 + * Utils::get_credentials() when omitted.
43 49 */
44 - public function init() {
50 + public function init($credentials = null) {
45 51 $this->settings = Utils::get_settings();
46 - $this->credentials = Utils::get_credentials();
52 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
47 53 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
48 54 ? $this->credentials['config']
49 55 : [];
50 56 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -52,18 +58,20 @@
52 58 : [];
53 59 $this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
54 60 ? $this->bucketConfig['bucket_name']
55 61 : '';
62 + $this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
63 + ? $this->credentials['cdn']
64 + : [];
56 65
57 66 if (
58 - isset($this->config['region']) && !empty($this->config['region']) &&
59 67 isset($this->config['access_key']) && !empty($this->config['access_key']) &&
60 68 isset($this->config['secret_key']) && !empty($this->config['secret_key']) &&
61 69 isset($this->config['endpoint']) && !empty($this->config['endpoint'])
62 70 ) {
63 - $this->S3Client = new S3Client([
71 + $this->S3CompatibleClient = new S3Client([
64 72 'version' => '2006-03-01',
65 - 'region' => $this->config['region'],
73 + 'region' => $this->config['region'] ? $this->config['region'] : 'us-east-1',
66 74 'endpoint' => $this->config['endpoint'], // DigitalOcean Spaces requires a custom endpoint
67 75 'use_accelerate_endpoint' => isset($this->bucketConfig['transfer_acceleration'])
68 76 ? $this->bucketConfig['transfer_acceleration'] : false,
69 77 'use_path_style_endpoint' => true, // DigitalOcean Spaces often requires path-style endpoints
@@ -82,17 +90,16 @@
82 90 * Verify Credentials
83 91 * @since 1.0.0
84 92 * @return boolean
85 93 */
86 - public function verifyCredentials($endpoint, $access_key, $secret_key, $region){
87 - if (
88 - isset($region) && !empty($region) &&
89 - isset($access_key) && !empty($access_key) &&
90 - isset($secret_key) && !empty($secret_key) &&
91 - isset($endpoint) && !empty($endpoint)
92 - ) {
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])) {
93 100 try {
94 - $S3Client = new S3Client([
101 + $S3CompatibleClient = new S3Client([
95 102 'version' => '2006-03-01',
96 103 'region' => $region ? $region : 'us-east-1',
97 104 'endpoint' => $endpoint,
98 105 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
@@ -102,9 +109,9 @@
102 109 'key' => $access_key,
103 110 'secret' => $secret_key,
104 111 ],
105 112 ]);
106 -
113 +
107 114 $result = [
108 115 'success' => false,
109 116 'code' => 200,
110 117 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
@@ -110,9 +117,9 @@
110 117 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
111 118 ];
112 119
113 120 try {
114 - $S3Client->listObjectsV2([
121 + $S3CompatibleClient->listObjectsV2([
115 122 'Bucket' => $this->token . '_dummy-bucket-for-auth-check'
116 123 ]);
117 124
118 125 // If we reach here, the credentials are valid
@@ -129,8 +136,9 @@
129 136 'NoSuchBucket',
130 137 'AllAccessDisabled',
131 138 'AuthorizationHeaderMalformed',
132 139 'PermanentRedirect',
140 + 'InvalidBucketName',
133 141 ];
134 142
135 143 if (in_array($code, $validErrors)) {
136 144 // If we reach here, the credentials are valid
@@ -162,17 +170,18 @@
162 170 * Verify Bucket
163 171 * @since 1.0.0
164 172 * @return boolean
165 173 */
166 - public function verifyBucketExist($endpoint, $access_key, $secret_key, $region, $bucket_name){
167 - if (
168 - isset($endpoint) && !empty($endpoint) &&
169 - isset($access_key) && !empty($access_key) &&
170 - isset($secret_key) && !empty($secret_key) &&
171 - isset($bucket_name) && !empty($bucket_name)
172 - ) {
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]) ) {
173 182 try {
174 - $S3Client = new S3Client([
183 + $S3CompatibleClient = new S3Client([
175 184 'version' => '2006-03-01',
176 185 'region' => $region ? $region : 'us-east-1',
177 186 'endpoint' => $endpoint,
178 187 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
@@ -186,9 +195,9 @@
186 195
187 196 //get S3 object
188 197 $bucket_found = false;
189 198 try {
190 - $S3Client->getObject([
199 + $S3CompatibleClient->getObject([
191 200 'Bucket' => $bucket_name,
192 201 'Key' => $this->token . '_dummy-object-for-bucket-exist-check'
193 202 ]);
194 203 $bucket_found = true;
@@ -218,15 +227,21 @@
218 227 * Create Bucket
219 228 * @since 1.0.0
220 229 * @return boolean
221 230 */
222 - public function createBucket($endpoint, $access_key, $secret_key, $region, $bucket_name){
223 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
231 + public function createBucket( $config = [], $bucketConfig = [] ){
232 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
233 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
234 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
235 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
236 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
237 +
238 + if (Service::has_missing_fields([$access_key, $secret_key, $bucket_name, $endpoint])) {
224 239 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
225 240 }
226 241
227 242 try {
228 - $S3Client = new S3Client([
243 + $S3CompatibleClient = new S3Client([
229 244 'version' => '2006-03-01',
230 245 'region' => $region ? $region : 'us-east-1',
231 246 'endpoint' => $endpoint,
232 247 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
@@ -238,31 +253,21 @@
238 253 ],
239 254 ]);
240 255
241 256 // Create Bucket
242 - $S3Client->createBucket([
257 + $S3CompatibleClient->createBucket([
243 258 'Bucket' => $bucket_name,
244 259 ]);
245 260
246 - // Optionally wait for bucket existence (recommended)
247 - $S3Client->waitUntil('BucketExists', ['Bucket' => $bucket_name]);
248 -
249 - try {
250 - $this->putBucketPolicy($bucket_name, $S3Client);
251 -
252 - return [
253 - 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
254 - 'data' => [
255 - 'Name' => $bucket_name,
256 - 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
257 - ],
258 - 'code' => 200,
259 - 'success' => true,
260 - ];
261 -
262 - } catch (AwsException $ex) {
263 - return ['message' => esc_html__('Bucket created. But the following error happened while setting the public access,', 'media-cloud-sync') . ' ' . $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
264 - }
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 + ];
265 270 } catch (AwsException $ex) {
266 271 return ['message' => $ex->getAwsErrorMessage(), 'code' => 200, 'success' => false];
267 272 } catch (S3Exception $ex) {
268 273 return ['message' => $ex->getAwsErrorMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
@@ -272,68 +277,24 @@
272 277 }
273 278
274 279
275 280 /**
276 - * Add Bucket Policy
277 - */
278 - private function putBucketPolicy($bucket, $S3Client = false) {
279 - if($S3Client == false) {
280 - $S3Client = $this->S3Client;
281 - }
282 -
283 - if(empty($bucket)) return false;
284 -
285 - $policy = json_encode([
286 - "Version" => "2012-10-17",
287 - "Statement" => [
288 - [
289 - "Effect" => "Allow",
290 - "Principal" => "*",
291 - "Action" => [
292 - "s3:DeleteObjectTagging",
293 - "s3:ListBucketMultipartUploads",
294 - "s3:DeleteObjectVersion",
295 - "s3:ListBucket",
296 - "s3:DeleteObjectVersionTagging",
297 - "s3:GetBucketAcl",
298 - "s3:ListMultipartUploadParts",
299 - "s3:PutObject",
300 - "s3:GetObjectAcl",
301 - "s3:GetObject",
302 - "s3:AbortMultipartUpload",
303 - "s3:DeleteObject",
304 - "s3:GetBucketLocation",
305 - "s3:PutObjectAcl",
306 - "s3:putBucketOwnershipControls",
307 - "s3:putBucketPolicy"
308 - ],
309 - "Resource" => [
310 - "arn:aws:s3:::$bucket/*",
311 - "arn:aws:s3:::$bucket"
312 - ]
313 - ]
314 - ]
315 - ]);
316 -
317 - // Add bucket policy
318 - $S3Client->putBucketPolicy(['Bucket' => $bucket, 'Policy' => $policy]);
319 -
320 - return true;
321 - }
322 -
323 -
324 -
325 - /**
326 281 * Check Bucket Write Permission
327 282 * @since 1.0.0
328 283 */
329 - public function verifyObjectWritePermission($endpoint, $access_key, $secret_key, $region, $bucket_name){
330 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
284 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ){
285 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
286 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
287 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
288 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
289 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
290 +
291 + if (Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name])) {
331 292 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
332 293 }
333 294
334 295 try {
335 - $S3Client = new S3Client([
296 + $S3CompatibleClient = new S3Client([
336 297 'version' => '2006-03-01',
337 298 'region' => $region ? $region : 'us-east-1',
338 299 'endpoint' => $endpoint,
339 300 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
@@ -344,19 +305,19 @@
344 305 'secret' => $secret_key,
345 306 ],
346 307 ]);
347 308
348 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
309 + $object_key = Utils::get_permission_check_object_key();
349 310
350 311
351 312 // Create a dummy object to check write permission
352 - $S3Client->putObject([
313 + $S3CompatibleClient->putObject([
353 314 'Bucket' => $bucket_name,
354 315 'Key' => $object_key,
355 316 'Body' => 'This is a test object to check write permission.',
356 317 ]);
357 318 // Check if the object was created successfully
358 - if ($S3Client->doesObjectExist($bucket_name, $object_key)) {
319 + if ($this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
359 320 return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
360 321 } else {
361 322 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
362 323 }
@@ -375,15 +336,21 @@
375 336 /**
376 337 * Check Bucket Delete Permission
377 338 * @since 1.0.0
378 339 */
379 - public function verifyObjectDeletePermission($endpoint, $access_key, $secret_key, $region, $bucket_name){
380 - if (empty($endpoint) || empty($access_key) || empty($secret_key) || empty($bucket_name)) {
340 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
341 + $region = isset($config['region']) && !empty($config['region']) ? $config['region'] : 'us-east-1';
342 + $access_key = isset($config['access_key']) ? $config['access_key'] : '';
343 + $secret_key = isset($config['secret_key']) ? $config['secret_key'] : '';
344 + $endpoint = isset($config['endpoint']) ? $config['endpoint'] : '';
345 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
346 +
347 + if (Service::has_missing_fields([$endpoint, $access_key, $secret_key, $bucket_name])) {
381 348 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
382 349 }
383 350
384 351 try {
385 - $S3Client = new S3Client([
352 + $S3CompatibleClient = new S3Client([
386 353 'version' => '2006-03-01',
387 354 'region' => $region ? $region : 'us-east-1',
388 355 'endpoint' => $endpoint,
389 356 'use_path_style_endpoint' => true, // Required for DigitalOcean Spaces
@@ -394,18 +361,18 @@
394 361 'secret' => $secret_key,
395 362 ],
396 363 ]);
397 364
398 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
365 + $object_key = Utils::get_permission_check_object_key();
399 366
400 367 // Create a dummy object to check dlete permission
401 - $S3Client->deleteObject([
368 + $S3CompatibleClient->deleteObject([
402 369 'Bucket' => $bucket_name,
403 370 'Key' => $object_key,
404 371 ]);
405 372
406 373 // Check if the object was created successfully
407 - if (!$S3Client->doesObjectExist($bucket_name, $object_key)) {
374 + if (!$this->exists($object_key, $bucket_name, $S3CompatibleClient)) {
408 375 return ['message' => esc_html__('Bucket delete permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
409 376 } else {
410 377 return ['message' => esc_html__('Bucket delete permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
411 378 }
@@ -418,25 +385,126 @@
418 385 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
419 386 }
420 387 }
421 388
389 +
422 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 + /**
423 474 * isConfigured Function To Identify the congfigurations are correct
424 475 * @since 1.0.0
425 476 */
426 477 public function isConfigured(){
427 - if ($this->S3Client) {
478 + if ($this->S3CompatibleClient) {
428 479 try {
429 - $buckets = $S3Client->listBuckets();
430 - if(!empty($buckets)){
431 - foreach($buckets as $bucket) {
432 - if ($bucket['Name']==$this->bucket_name) {
433 - return true;
434 - }
435 - }
436 - }
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) {
437 505 return false;
438 - } catch (AwsException $ex) {
506 + } catch (Exception $ex) {
439 507 return false;
440 508 }
441 509 }
442 510 return false;
@@ -448,10 +516,11 @@
448 516 *
449 517 */
450 518 public function toPrivate($key) {
451 519 if(!$key) return false;
520 + if(!$this->S3CompatibleClient) return false;
452 521 try {
453 - $this->S3Client->putObjectAcl([
522 + $this->S3CompatibleClient->putObjectAcl([
454 523 'Bucket' => $this->bucket_name,
455 524 'Key' => $key,
456 525 'ACL' => 'private'
457 526 ]);
@@ -458,9 +527,8 @@
458 527 return true;
459 528 } catch (AwsException $ex) {
460 529 return false;
461 530 }
462 - return false;
463 531 }
464 532
465 533
466 534
@@ -466,23 +534,23 @@
466 534
467 535 /**
468 536 * Make Object Public
469 537 * @since 1.0.0
470 - *
538 + *
471 539 */
472 540 public function toPublic($key) {
473 541 if(!$key) return false;
542 + if(!$this->S3CompatibleClient) return false;
474 543 try {
475 - $this->S3Client->putObjectAcl([
544 + $this->S3CompatibleClient->putObjectAcl([
476 545 'Bucket' => $this->bucket_name,
477 546 'Key' => $key,
478 547 'ACL' => 'public-read'
479 - ]);
548 + ]);
480 549 return true;
481 550 } catch (AwsException $ex) {
482 551 return false;
483 552 }
484 - return false;
485 553 }
486 554
487 555
488 556
@@ -489,112 +557,205 @@
489 557 /**
490 558 * Check the object exist
491 559 * @since 1.1.8
492 560 */
493 - public function exists($key) {
561 + public function exists($key, $bucket_name = '', $client = null) {
494 562 if(!$key) return false;
495 563
496 - if($this->S3Client->doesObjectExist($this->bucket_name, $key)) {
497 - return true;
564 + try {
565 + $bucket_name = $bucket_name ? $bucket_name : $this->bucket_name;
566 + $client = $client ?? $this->S3CompatibleClient;
567 + if($client->doesObjectExistV2($bucket_name, $key)) {
568 + return true;
569 + }
570 + return false;
571 + } catch (AwsException $ex) {
572 + return false;
573 + } catch (S3Exception $ex) {
574 + return false;
575 + } catch (Exception $ex) {
576 + return false;
498 577 }
499 -
500 - return false;
501 578 }
502 579
503 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 + /**
504 633 * Upload Single
505 634 * @since 1.0.0
506 635 * @return boolean
507 636 */
508 - public function uploadSingle($media_absolute_path, $media_path, $prefix='') {
509 - $result = array();
637 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false) {
510 638 if (
511 - isset($media_absolute_path) && !empty($media_absolute_path) &&
512 - isset($media_path) && !empty($media_path)
639 + isset($absolute_source_path) && !empty($absolute_source_path) &&
640 + isset($relative_source_path) && !empty($relative_source_path)
513 641 ) {
514 - $file_name = wp_basename( $media_path );
642 + $file_name = wp_basename( $relative_source_path );
515 643 if ($file_name) {
516 - $upload_path = Utils::generate_object_key($media_path, $prefix);
517 -
518 - // Decide Multipart upload or normal put object
519 - if (filesize($media_absolute_path) <= Schema::getConstant('DOCEAN_MULTIPART_MIN_FILE_SIZE')) {
520 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
521 - try {
522 - $upload = $this->S3Client->putObject([
523 - 'Bucket' => $this->bucket_name,
524 - 'Key' => $upload_path,
525 - 'Body' => fopen($media_absolute_path, 'r'),
526 - ]);
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 + }
527 666
528 - $result = array(
529 - 'success' => true,
530 - 'code' => 200,
531 - 'file_url' => $this->generate_file_url($upload_path),
532 - 'key' => $upload_path,
533 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
534 - );
535 - } catch (AwsException $e) {
536 - $result = array(
537 - 'success' => false,
538 - 'code' => 200,
539 - 'message' => $e->getMessage()
540 - );
541 - }
542 - } else {
543 - $multiUploader = new MultipartUploader($this->S3Client, $media_absolute_path, [
544 - 'bucket' => $this->bucket_name,
545 - 'key' => $upload_path
546 - ]);
547 -
548 - try {
549 - do {
550 - try {
551 - $uploaded = $multiUploader->upload();
552 - } catch (MultipartUploadException $e) {
553 - $multiUploader = new MultipartUploader($this->S3Client, $media_absolute_path, [
554 - 'state' => $e->getState(),
555 - ]);
556 - }
557 - } while (!isset($uploaded));
667 + /**
668 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
669 + * @since 1.4.0
670 + */
671 + public function uploadObjectAtKey($absolute_source_path, $key) {
672 + return $this->execute_upload($absolute_source_path, $key);
673 + }
558 674
559 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
560 - $result = array(
561 - 'success' => true,
562 - 'code' => 200,
563 - 'file_url' => $this->generate_file_url($upload_path),
564 - 'key' => $upload_path,
565 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
566 - );
567 - } else {
568 - $result = array(
569 - 'success' => false,
570 - 'code' => 200,
571 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
572 - );
573 - }
574 - } catch (MultipartUploadException $e) {
575 - $result = array(
576 - 'success' => false,
577 - 'code' => 200,
578 - 'message' => $e->getMessage()
579 - );
580 - }
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 + ];
581 742 }
582 - } else {
583 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
584 746 'success' => false,
585 747 'code' => 200,
586 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
587 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
588 756 }
589 - } else {
590 - $result = array(
591 - 'success' => false,
592 - 'code' => 200,
593 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
594 - );
595 757 }
596 - return $result;
597 758 }
598 759
599 760 /**
600 761 * Save object to server
@@ -600,10 +761,11 @@
600 761 * Save object to server
601 762 * @since 1.0.0
602 763 */
603 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
604 766 try {
605 - $getObject = $this->S3Client->GetObject([
767 + $getObject = $this->S3CompatibleClient->GetObject([
606 768 'Bucket' => $this->bucket_name,
607 769 'Key' => $key,
608 770 'SaveAs' => $save_path
609 771 ]);
@@ -615,10 +777,157 @@
615 777 }
616 778 return false;
617 779 }
618 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 + }
619 798
620 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 + /**
621 930 * Delete Single
622 931 * @since 1.0.0
623 932 * @return boolean
624 933 */
@@ -623,16 +932,23 @@
623 932 * @return boolean
624 933 */
625 934 public function deleteSingle($key) {
626 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 + }
627 943 if (isset($key) && !empty($key)) {
628 944 try {
629 - $this->S3Client->deleteObject([
945 + $this->S3CompatibleClient->deleteObject([
630 946 'Bucket' => $this->bucket_name,
631 947 'Key' => $key
632 948 ]);
633 949
634 - if (!$this->S3Client->doesObjectExist($this->bucket_name, $key)) {
950 + if (!$this->exists($key)) {
635 951 $result = array(
636 952 'success' => true,
637 953 'code' => 200,
638 954 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -661,37 +977,44 @@
661 977 return $result;
662 978 }
663 979
664 980 /**
665 - * get presigned URL
981 + * get private URL
666 982 * @since 1.0.0
667 983 * @return boolean
668 984 */
669 - public function get_presigned_url($key) {
985 + public function get_private_url($key) {
670 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 + }
671 994 if (isset($key) && !empty($key)) {
672 995 try {
673 - $cmd = $this->S3Client->getCommand('GetObject', [
996 + $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
674 997 'Bucket' => $this->bucket_name,
675 998 'Key' => $key
676 999 ]);
677 1000
678 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1001 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
679 1002
680 - $request = $this->S3Client->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1003 + $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
681 1004
682 - if ($presignedUrl = (string)$request->getUri()) {
1005 + if ($privateUrl = (string)$request->getUri()) {
683 1006 $result = array(
684 1007 'success' => true,
685 1008 'code' => 200,
686 - 'file_url' => $presignedUrl,
687 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync')
1009 + 'file_url' => $privateUrl,
1010 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync')
688 1011 );
689 1012 } else {
690 1013 $result = array(
691 1014 'success' => false,
692 1015 'code' => 200,
693 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1016 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
694 1017 );
695 1018 }
696 1019 } catch (AwsException $e) {
697 1020 $result = array(
@@ -698,8 +1021,20 @@
698 1021 'success' => false,
699 1022 'code' => 200,
700 1023 'message' => $e->getMessage()
701 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 + );
702 1037 }
703 1038 } else {
704 1039 $result = array(
705 1040 'success' => false,
@@ -712,9 +1047,9 @@
712 1047
713 1048 /**
714 1049 * Generate file URL
715 1050 */
716 - private function generate_file_url($key){
1051 + public function generate_file_url($key){
717 1052 $domain = $this->get_domain();
718 1053
719 1054 return apply_filters('wpmcs_generate_do_file_url',
720 1055 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -721,8 +1056,17 @@
721 1056 $domain,
722 1057 $this->bucket_name,
723 1058 $key
724 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);
725 1069 }
726 1070
727 1071 /**
728 1072 * Get domain URL