PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 All 36 releases
← All changes | includes/base/services/s3compatible.php +575 -228 1.2.0 → 1.4.2 View file →
@@ -8,8 +8,10 @@
8 8 use Dudlewebs\WPMCS\s3\Aws\Exception\AwsException;
9 9 use Dudlewebs\WPMCS\s3\Aws\S3\Exception\S3Exception;
10 10 use Dudlewebs\WPMCS\s3\Aws\S3\MultipartUploader;
11 11 use Dudlewebs\WPMCS\s3\Aws\Exception\MultipartUploadException;
12 +use Dudlewebs\WPMCS\s3\Aws\S3\ObjectUploader;
13 +use Dudlewebs\WPMCS\s3\Aws\Command;
12 14 use Exception;
13 15
14 16 class S3Compatible {
15 17 private $assets_url;
@@ -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,114 +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 - 'ACL' => 'public-read', // make file 'public'
527 - ]);
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 + }
528 666
529 - $result = array(
530 - 'success' => true,
531 - 'code' => 200,
532 - 'file_url' => $this->generate_file_url($upload_path),
533 - 'key' => $upload_path,
534 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
535 - );
536 - } catch (AwsException $e) {
537 - $result = array(
538 - 'success' => false,
539 - 'code' => 200,
540 - 'message' => $e->getMessage()
541 - );
542 - }
543 - } else {
544 - $multiUploader = new MultipartUploader($this->S3Client, $media_absolute_path, [
545 - 'bucket' => $this->bucket_name,
546 - 'key' => $upload_path,
547 - 'acl' => 'public-read', // make file 'public'
548 - ]);
549 -
550 - try {
551 - do {
552 - try {
553 - $uploaded = $multiUploader->upload();
554 - } catch (MultipartUploadException $e) {
555 - $multiUploader = new MultipartUploader($this->S3Client, $media_absolute_path, [
556 - 'state' => $e->getState(),
557 - ]);
558 - }
559 - } 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 + }
560 674
561 - if (isset($uploaded['ObjectURL']) && !empty($uploaded['ObjectURL'])) {
562 - $result = array(
563 - 'success' => true,
564 - 'code' => 200,
565 - 'file_url' => $this->generate_file_url($upload_path),
566 - 'key' => $upload_path,
567 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync')
568 - );
569 - } else {
570 - $result = array(
571 - 'success' => false,
572 - 'code' => 200,
573 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync')
574 - );
575 - }
576 - } catch (MultipartUploadException $e) {
577 - $result = array(
578 - 'success' => false,
579 - 'code' => 200,
580 - 'message' => $e->getMessage()
581 - );
582 - }
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 + ];
583 742 }
584 - } else {
585 - $result = array(
743 + $options = ['state' => $e->getState()];
744 + } catch (AwsException $e) {
745 + return [
586 746 'success' => false,
587 747 'code' => 200,
588 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync')
589 - );
748 + 'message' => $e->getMessage()
749 + ];
750 + } catch (Exception $e) {
751 + return [
752 + 'success' => false,
753 + 'code' => 200,
754 + 'message' => $e->getMessage()
755 + ];
590 756 }
591 - } else {
592 - $result = array(
593 - 'success' => false,
594 - 'code' => 200,
595 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync')
596 - );
597 757 }
598 - return $result;
599 758 }
600 759
601 760 /**
602 761 * Save object to server
@@ -602,10 +761,11 @@
602 761 * Save object to server
603 762 * @since 1.0.0
604 763 */
605 764 public function object_to_server($key, $save_path) {
765 + if(!$this->S3CompatibleClient) return false;
606 766 try {
607 - $getObject = $this->S3Client->GetObject([
767 + $getObject = $this->S3CompatibleClient->GetObject([
608 768 'Bucket' => $this->bucket_name,
609 769 'Key' => $key,
610 770 'SaveAs' => $save_path
611 771 ]);
@@ -617,10 +777,157 @@
617 777 }
618 778 return false;
619 779 }
620 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 + }
621 798
622 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 + /**
623 930 * Delete Single
624 931 * @since 1.0.0
625 932 * @return boolean
626 933 */
@@ -625,16 +932,23 @@
625 932 * @return boolean
626 933 */
627 934 public function deleteSingle($key) {
628 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 + }
629 943 if (isset($key) && !empty($key)) {
630 944 try {
631 - $this->S3Client->deleteObject([
945 + $this->S3CompatibleClient->deleteObject([
632 946 'Bucket' => $this->bucket_name,
633 947 'Key' => $key
634 948 ]);
635 949
636 - if (!$this->S3Client->doesObjectExist($this->bucket_name, $key)) {
950 + if (!$this->exists($key)) {
637 951 $result = array(
638 952 'success' => true,
639 953 'code' => 200,
640 954 'message' => esc_html__('Deleted Successfully', 'media-cloud-sync')
@@ -663,37 +977,44 @@
663 977 return $result;
664 978 }
665 979
666 980 /**
667 - * get presigned URL
981 + * get private URL
668 982 * @since 1.0.0
669 983 * @return boolean
670 984 */
671 - public function get_presigned_url($key) {
985 + public function get_private_url($key) {
672 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 + }
673 994 if (isset($key) && !empty($key)) {
674 995 try {
675 - $cmd = $this->S3Client->getCommand('GetObject', [
996 + $cmd = $this->S3CompatibleClient->getCommand('GetObject', [
676 997 'Bucket' => $this->bucket_name,
677 998 'Key' => $key
678 999 ]);
679 1000
680 - $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;
681 1002
682 - $request = $this->S3Client->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
1003 + $request = $this->S3CompatibleClient->createPresignedRequest($cmd, sprintf('+%s minutes', $expires));
683 1004
684 - if ($presignedUrl = (string)$request->getUri()) {
1005 + if ($privateUrl = (string)$request->getUri()) {
685 1006 $result = array(
686 1007 'success' => true,
687 1008 'code' => 200,
688 - 'file_url' => $presignedUrl,
689 - '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')
690 1011 );
691 1012 } else {
692 1013 $result = array(
693 1014 'success' => false,
694 1015 'code' => 200,
695 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync')
1016 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync')
696 1017 );
697 1018 }
698 1019 } catch (AwsException $e) {
699 1020 $result = array(
@@ -700,8 +1021,20 @@
700 1021 'success' => false,
701 1022 'code' => 200,
702 1023 'message' => $e->getMessage()
703 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 + );
704 1037 }
705 1038 } else {
706 1039 $result = array(
707 1040 'success' => false,
@@ -714,9 +1047,9 @@
714 1047
715 1048 /**
716 1049 * Generate file URL
717 1050 */
718 - private function generate_file_url($key){
1051 + public function generate_file_url($key){
719 1052 $domain = $this->get_domain();
720 1053
721 1054 return apply_filters('wpmcs_generate_do_file_url',
722 1055 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -726,11 +1059,25 @@
726 1059 );
727 1060 }
728 1061
729 1062 /**
1063 + * Is Provider URL
1064 + * @since 1.3.6
1065 + */
1066 + public function is_provider_url($url) {
1067 + $domain = $this->get_domain();
1068 + return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
1069 + }
1070 +
1071 + /**
730 1072 * Get domain URL
731 1073 */
732 1074 public function get_domain($region = '') {
733 1075 return $this->config['endpoint'];
1076 + }
1077 +
1078 + /** Exposes the already-constructed SDK client for StreamWrapper's registration — avoids reconstructing one from credentials. */
1079 + public function get_client() {
1080 + return $this->S3CompatibleClient;
734 1081 }
735 1082
736 1083 }