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/gcloud.php +758 -186 1.2.61.4.1 View file →
@@ -3,10 +3,11 @@
3 3
4 4 defined('ABSPATH') || exit;
5 5
6 6 // Libraries
7 -use Dudlewebs\WPMCS\Google\Cloud\Storage\StorageClient;
8 -use Dudlewebs\WPMCS\Google\Cloud\Core\Exception\ServiceException;
7 +use Dudlewebs\WPMCS\GCP\Google\Cloud\Storage\StorageClient;
8 +use Dudlewebs\WPMCS\GCP\Google\Cloud\Core\Exception\ServiceException;
9 +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialsLoader;
9 10
10 11 use Exception;
11 12
12 13 class GCloud {
@@ -28,23 +29,26 @@
28 29 /**
29 30 * Admin constructor.
30 31 * @since 1.0.0
31 32 */
32 - public function __construct() {
33 + public function __construct($credentials = null) {
33 34 $this->assets_url = WPMCS_ASSETS_URL;
34 35 $this->version = WPMCS_VERSION;
35 36 $this->token = WPMCS_TOKEN;
36 37
37 38 // Initialize setup
38 - $this->init();
39 + $this->init($credentials);
39 40 }
40 41
41 42 /**
42 43 * Initialise Client
44 + *
45 + * @param array|null $credentials Optional explicit credentials; falls back to
46 + * Utils::get_credentials() when omitted.
43 47 */
44 - public function init() {
48 + public function init($credentials = null) {
45 49 $this->settings = Utils::get_settings();
46 - $this->credentials = Utils::get_credentials();
50 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
47 51 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
48 52 ? $this->credentials['config']
49 53 : [];
50 54 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -58,28 +62,34 @@
58 62 : [];
59 63
60 64 if (
61 65 isset($this->config['config_json']) && !empty($this->config['config_json']) &&
62 - isset($this->config['config_json']['path']) && !empty($this->config['config_json']['path']) &&
63 66 isset($this->bucket_name) && !empty($this->bucket_name)
64 67 ) {
65 - if(file_exists($this->config['config_json']['path'])){
68 + if(Utils::is_json($this->config['config_json'])){
66 69 // Set google client
67 - $this->gcloudClient = new StorageClient([
68 - 'keyFilePath' => $this->config['config_json']['path'],
69 - ]);
70 - // Set bucket object
71 - $this->bucket = $this->gcloudClient->bucket($this->bucket_name);
70 + $keyArray = json_decode($this->config['config_json'], true);
71 +
72 + if (is_array($keyArray)) {
73 + $this->gcloudClient = new StorageClient([
74 + 'keyFile' => $keyArray,
75 + ]);
76 + $this->bucket = $this->gcloudClient->bucket($this->bucket_name);
77 + } else {
78 + // Handle JSON decode failure
79 + throw new \Exception('Invalid JSON provided for GCloud credentials.');
80 + }
72 81 } else {
73 82 add_action('admin_notices', function (){
74 - echo wp_kses_post(sprintf( "<div class='error'><p><strong>%s: </strong><br>Google Cloud Storage configuration file missing from the directory.
75 - It may break the media url's as well as media uploads.<br>
76 - <a href='%s'>Re-configure</a> plugin to fix the issue.
77 - </p></div>",
78 - esc_html__('Media Cloud Sync', 'media-cloud-sync'),
79 - admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
80 - ));
83 + echo wp_kses_post(sprintf( "<div class='error'><p><strong>%s: </strong><br>Google Cloud Storage configuration is invalid.
84 + It may break the media url's as well as media uploads.<br>
85 + <a href='%s'>Re-configure</a> plugin to fix the issue.
86 + </p></div>",
87 + esc_html__('Media Cloud Sync', 'media-cloud-sync'),
88 + admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
89 + ));
81 90 });
91 +
82 92 }
83 93 }
84 94 }
85 95
@@ -87,18 +97,32 @@
87 97 * Verify Credentials
88 98 * @since 1.0.0
89 99 * @return boolean
90 100 */
91 - public function verifyCredentials( $config_file ){
92 - if (
93 - isset($config_file) && !empty($config_file) &&
94 - isset($config_file['path']) && !empty($config_file['path']) &&
95 - file_exists($config_file['path'])
96 - ) {
101 + public function verifyCredentials( $config = [] ){
102 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
103 + if (!Service::has_missing_fields([$config_json])) {
104 + if(!Utils::is_json($config_json)){
105 + return [
106 + 'success' => false,
107 + 'code' => 200,
108 + 'message' => esc_html__('Invalid JSON configuration, please try again', 'media-cloud-sync'),
109 + ];
110 + }
111 +
97 112 try {
98 - $googleClient = new StorageClient([
99 - 'keyFilePath' => $config_file['path'],
100 - ]);
113 + $config_array = json_decode($config_json, true);
114 + if (is_array($config_array)) {
115 + $googleClient = new StorageClient([
116 + 'keyFile' => $config_array
117 + ]);
118 + } else {
119 + return [
120 + 'success' => false,
121 + 'code' => 200,
122 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
123 + ];
124 + }
101 125
102 126 $result = [
103 127 'success' => false,
104 128 'code' => 200,
@@ -169,17 +193,32 @@
169 193 * Verify Bucket Exists
170 194 * @since 1.0.0
171 195 * @return boolean
172 196 */
173 - public function verifyBucketExist( $config_file, $bucket_name ){
174 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($bucket_name) ) ) {
197 + public function verifyBucketExist( $config = [], $bucketConfig = [] ){
198 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
199 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
200 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
175 201 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
176 202 }
177 203
204 + if ( !Utils::is_json( $config_json ) ) {
205 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
206 + }
207 +
178 208 try {
179 - $googleClient = new StorageClient([
180 - 'keyFilePath' => $config_file['path'],
181 - ]);
209 + $config_array = json_decode($config_json, true);
210 + if (is_array($config_array)) {
211 + $googleClient = new StorageClient([
212 + 'keyFile' => $config_array
213 + ]);
214 + } else {
215 + return [
216 + 'success' => false,
217 + 'code' => 200,
218 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
219 + ];
220 + }
182 221
183 222 try {
184 223 $bucket = $googleClient->bucket($bucket_name);
185 224
@@ -202,15 +241,8 @@
202 241 'code' => 200,
203 242 'success' => false,
204 243 ];
205 244 }
206 -
207 -
208 - if($bucket_found) {
209 - return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
210 - } else {
211 - return array('message' => esc_html__("Bucket choosen does not exist / does not have read permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
212 - }
213 245 } catch (Exception $ex) {
214 246 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
215 247 }
216 248 }
@@ -220,17 +252,33 @@
220 252 * Create Bucket
221 253 * @since 1.0.0
222 254 * @return boolean
223 255 */
224 - public function createBucket( $config_file, $region, $bucket_name ){
225 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($region) && !empty($bucket_name) ) ) {
256 + public function createBucket( $config = [], $bucketConfig = [] ){
257 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
258 + $region = isset($bucketConfig['region']) ? $bucketConfig['region'] : '';
259 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
260 + if ( Service::has_missing_fields([$config_json, $region, $bucket_name]) ) {
226 261 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
227 262 }
228 263
264 + if ( !Utils::is_json( $config_json ) ) {
265 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
266 + }
267 +
229 268 try {
230 - $googleClient = new StorageClient([
231 - 'keyFilePath' => $config_file['path'],
232 - ]);
269 + $config_array = json_decode($config_json, true);
270 + if (is_array($config_array)) {
271 + $googleClient = new StorageClient([
272 + 'keyFile' => $config_array
273 + ]);
274 + } else {
275 + return [
276 + 'success' => false,
277 + 'code' => 200,
278 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
279 + ];
280 + }
233 281
234 282 // Create Bucket
235 283 $bucket = $googleClient->createBucket($bucket_name, [
236 284 'location' => $region,
@@ -288,9 +336,9 @@
288 336 'success' => true,
289 337 ];
290 338
291 339 } catch (Exception $ex) {
292 - return ['message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
340 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
293 341 }
294 342 }
295 343
296 344 /**
@@ -296,18 +344,32 @@
296 344 /**
297 345 * Check Bucket Write Permission
298 346 * @since 1.0.0
299 347 */
300 - public function verifyObjectWritePermission($config_file, $bucket_name){
301 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($bucket_name) ) ) {
348 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
349 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
350 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
351 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
302 352 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
303 353 }
304 354
355 + if ( !Utils::is_json( $config_json ) ) {
356 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
357 + }
358 +
305 359 try {
306 - $googleClient = new StorageClient([
307 - 'keyFilePath' => $config_file['path'],
308 - ]);
309 -
360 + $config_array = json_decode($config_json, true);
361 + if (is_array($config_array)) {
362 + $googleClient = new StorageClient([
363 + 'keyFile' => $config_array
364 + ]);
365 + } else {
366 + return [
367 + 'success' => false,
368 + 'code' => 200,
369 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
370 + ];
371 + }
310 372 $bucket = $googleClient->bucket($bucket_name);
311 373 if ($bucket->exists()) {
312 374 $bucket_found = true;
313 375 } else {
@@ -313,9 +375,9 @@
313 375 } else {
314 376 return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
315 377 }
316 378 if ($bucket_found) {
317 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
379 + $object_key = Utils::get_permission_check_object_key();
318 380
319 381 // Prepare a temporary file with content to check write permission
320 382 $stream = fopen('php://temp', 'r+');
321 383 fwrite($stream, 'This is a test object to check write permission.');
@@ -327,14 +389,11 @@
327 389 [
328 390 'name' => $object_key,
329 391 ]
330 392 );
331 - if(is_resource($stream)) {
332 - fclose($stream);
333 - }
334 393
335 394 if ($object->exists()) {
336 - return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
395 + return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
337 396 } else {
338 397 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
339 398 }
340 399 }
@@ -339,8 +398,12 @@
339 398 }
340 399 }
341 400 } catch (Exception $ex) {
342 401 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
402 + } finally {
403 + if (isset($stream) && is_resource($stream)) {
404 + fclose($stream);
405 + }
343 406 }
344 407 }
345 408
346 409 /**
@@ -346,21 +409,36 @@
346 409 /**
347 410 * Check Bucket Delete Permission
348 411 * @since 1.0.0
349 412 */
350 - public function verifyObjectDeletePermission($config_file, $bucket_name){
351 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($bucket_name) ) ) {
413 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
414 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
415 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
416 +
417 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
352 418 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
353 419 }
420 + if( !Utils::is_json( $config_json ) ) {
421 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
422 + }
354 423
355 424 try {
356 - $googleClient = new StorageClient([
357 - 'keyFilePath' => $config_file['path'],
358 - ]);
425 + $config_array = json_decode($config_json, true);
426 + if (is_array($config_array)) {
427 + $googleClient = new StorageClient([
428 + 'keyFile' => $config_array
429 + ]);
430 + } else {
431 + return [
432 + 'success' => false,
433 + 'code' => 200,
434 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
435 + ];
436 + }
359 437
360 438 $bucket = $googleClient->bucket($bucket_name);
361 439 try {
362 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
440 + $object_key = Utils::get_permission_check_object_key();
363 441
364 442 // Try fetching a dummy object to test access
365 443 $object = $bucket->object($object_key);
366 444 if ($object->exists()) {
@@ -386,9 +464,9 @@
386 464 ];
387 465 }
388 466 } catch (Exception $ex) {
389 467 return [
390 - 'message' => esc_html__('Object does not exist or credentials are invalid: ', 'media-cloud-sync') . $e->getMessage(),
468 + 'message' => esc_html__('Object does not exist or credentials are invalid: ', 'media-cloud-sync') . $ex->getMessage(),
391 469 'code' => 200,
392 470 'success' => false,
393 471 ];
394 472 }
@@ -407,11 +485,10 @@
407 485 'status' => false,
408 486 'message' => '',
409 487 'lastChecked' => time(),
410 488 ];
411 - if (empty($this->gcloudClient) || empty($this->bucket_name)) {
489 + if (Service::has_missing_fields([$this->gcloudClient, $this->bucket_name])) {
412 490 $result['message'] = esc_html__('Please check the authorization details', 'media-cloud-sync');
413 - Utils::set_status('cdnRead', $result);
414 491 return [
415 492 'message' => $result['message'],
416 493 'code' => 200,
417 494 'success' => false,
@@ -419,12 +496,12 @@
419 496 ];
420 497 }
421 498
422 499 try {
423 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
500 + $object_key = Utils::get_permission_check_object_key();
424 501
425 502 // Check if the object was created successfully
426 - if (!$this->bucket->object($object_key)->exists()) {
503 + if (!$this->exists($object_key)) {
427 504 // Create a dummy object to check write permission
428 505 $stream = fopen('php://temp', 'r+');
429 506 fwrite($stream, 'This is a test object to check read permission.');
430 507 rewind($stream);
@@ -431,18 +508,15 @@
431 508 $this->bucket->upload(
432 509 $stream,
433 510 [
434 511 'name' => $object_key,
512 + 'metadata' => ['cacheControl' => 'no-cache, no-store, must-revalidate'],
435 513 ]
436 514 );
437 - if (is_resource($stream)) {
438 - fclose($stream);
439 - }
440 515 // Re-check if the object was created successfully
441 - if (!$this->bucket->object($object_key)->exists()) {
516 + if (!$this->exists($object_key)) {
442 517 $result['status'] = false;
443 518 $result['message'] = esc_html__('Failed to create an object for read permission check, please check service configuration', 'media-cloud-sync');
444 - Utils::set_status('cdnRead', $result);
445 519 return [
446 520 'message' => $result['message'],
447 521 'code' => 200,
448 522 'success' => false,
@@ -453,24 +527,30 @@
453 527
454 528 $url = $this->generate_file_url($object_key);
455 529 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
456 530
457 - $headers = @get_headers($cdn_url);
458 - if (strpos($headers[0], '200') !== false) {
531 + // Never trust a cached response for this fixed, predictable URL — a stale cached
532 + // error would otherwise keep failing the check long after real access is fine.
533 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
534 + $headers = @get_headers($cdn_url, false, $no_cache_context);
535 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
536 + ? (int) $matches[1]
537 + : 0;
538 +
539 + if ($status_code === 200) {
459 540 $result['status'] = true;
460 541 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
461 - } else if (strpos($headers[0], '403') !== false) {
542 + } else if ($status_code === 403) {
462 543 $result['status'] = false;
463 - if($this->cdnConfig['service'] == $this->service) {
544 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
464 545 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
465 546 } else {
466 547 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
467 548 }
468 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
469 - } else if (strpos($headers[0], '404') !== false) {
549 + } else if ($status_code === 404) {
470 550 $result['status'] = false;
471 551 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
472 - } else if (strpos($headers[0], '500') !== false) {
552 + } else if ($status_code === 500) {
473 553 $result['status'] = false;
474 554 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
475 555 } else {
476 556 $result['status'] = false;
@@ -475,9 +555,8 @@
475 555 } else {
476 556 $result['status'] = false;
477 557 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
478 558 }
479 - Utils::set_status('cdnRead', $result);
480 559 $this->deleteSingle($object_key);
481 560 return [
482 561 'message' => $result['message'],
483 562 'code' => 200,
@@ -484,11 +563,17 @@
484 563 'success' => $result['status'],
485 564 'lastChecked' => $result['lastChecked'],
486 565 ];
487 566 } catch (ServiceException $ex) {
488 - return ['message' => $ex->getMessage(), 'code' => 200, 'success' => false];
567 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
568 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
489 569 } catch (Exception $ex) {
490 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
570 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
571 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
572 + } finally {
573 + if (isset($stream) && is_resource($stream)) {
574 + fclose($stream);
575 + }
491 576 }
492 577 }
493 578
494 579 /**
@@ -499,9 +584,9 @@
499 584 if ($this->gcloudClient) {
500 585 try {
501 586 $bucket = $this->gcloudClient->bucket($this->token . '_dummy-bucket-for-auth-check');
502 587 $exists = $bucket->exists(); // Triggers the API call
503 - return false;
588 + return true;
504 589 } catch (ServiceException $e) {
505 590 $statusCode = $e->getCode();
506 591
507 592 $validErrors = [200, 403, 404];
@@ -524,8 +609,9 @@
524 609 *
525 610 */
526 611 public function toPrivate($key) {
527 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
528 614
529 615 try {
530 616 $object = $this->bucket->object($key);
531 617 if ($object->exists()) {
@@ -545,12 +631,13 @@
545 631
546 632 /**
547 633 * Make Object Public
548 634 * @since 1.0.0
549 - *
635 + *
550 636 */
551 637 public function toPublic($key) {
552 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
553 640
554 641 try {
555 642 $object = $this->bucket->object($key);
556 643 if ($object->exists()) {
@@ -566,18 +653,309 @@
566 653 return false;
567 654 }
568 655 }
569 656
657 + /**
658 + * Fetch the bucket's IAM policy with the plugin's own
659 + * allUsers:roles/storage.objectViewer binding(s) dropped — shared by
660 + * both drop_bucket_level_grant() and restore_bucket_level_grant() so
661 + * the find-and-drop logic isn't written twice. Every other binding
662 + * (project owners/editors, other service accounts, etc.) is left
663 + * exactly as found, unlike S3 where the whole policy is safely one
664 + * plugin-owned statement.
665 + * @since 1.4.1
666 + */
667 + private function bucket_policy_without_own_binding() {
668 + $iam = $this->bucket->iam();
669 + $policy = $iam->policy(['requestedPolicyVersion' => 3]);
570 670
671 + $bindings = [];
672 + foreach (($policy['bindings'] ?? []) as $binding) {
673 + if (
674 + isset($binding['role'], $binding['members']) &&
675 + $binding['role'] === 'roles/storage.objectViewer' &&
676 + in_array('allUsers', (array) $binding['members'], true)
677 + ) {
678 + continue;
679 + }
680 + $bindings[] = $binding;
681 + }
682 +
683 + return ['iam' => $iam, 'policy' => $policy, 'bindings' => $bindings];
684 + }
685 +
571 686 /**
572 - * Check the object exist
687 + * Drop the plugin's bucket-wide allUsers:objectViewer binding, if any,
688 + * and do not re-add it — used by the enable path, once the
689 + * Managed-Folder-scoped grant is already confirmed in effect.
690 + * @since 1.4.1
691 + */
692 + private function drop_bucket_level_grant() {
693 + $state = $this->bucket_policy_without_own_binding();
694 + $state['policy']['bindings'] = $state['bindings'];
695 + $state['policy']['version'] = 3;
696 + $state['iam']->setPolicy($state['policy'], ['requestedPolicyVersion' => 3]);
697 + }
698 +
699 + /**
700 + * Find-and-drop then re-add exactly one bucket-wide
701 + * allUsers:objectViewer binding — mirrors createBucket()'s original
702 + * grant. Used by the disable path to restore the plugin's original,
703 + * pre-private-media public-access mechanism; find-and-drop-first
704 + * guarantees a repeated apply/remove cycle never accumulates
705 + * duplicate bindings.
706 + * @since 1.4.1
707 + */
708 + private function restore_bucket_level_grant() {
709 + $state = $this->bucket_policy_without_own_binding();
710 + $state['bindings'][] = [
711 + 'role' => 'roles/storage.objectViewer',
712 + 'members' => ['allUsers'],
713 + ];
714 + $state['policy']['bindings'] = $state['bindings'];
715 + $state['policy']['version'] = 3;
716 + $state['iam']->setPolicy($state['policy'], ['requestedPolicyVersion' => 3]);
717 + }
718 +
719 + /**
720 + * Hand-written, authenticated REST call against GCS's Managed Folders
721 + * API (storage/v1/b/{bucket}/managedFolders/...) — the vendored SDK has
722 + * no native class for this resource. Mints a fresh Guzzle client from
723 + * the same service-account JSON already trusted for the ordinary
724 + * StorageClient, since Bucket::$connection/StorageClient::$connection
725 + * have no public accessor into their internal auth machinery.
726 + *
727 + * $http_errors is disabled so 4xx/5xx responses are returned (not
728 + * thrown) — callers need to distinguish e.g. 409 (already exists) and
729 + * 404 (already gone) from genuine failures, which is far cleaner done
730 + * by inspecting the status code than by parsing exception messages.
731 + * @since 1.4.1
732 + */
733 + private function managed_folder_iam_request($method, $path, $body = null) {
734 + $keyArray = json_decode($this->config['config_json'], true);
735 + $fetcher = CredentialsLoader::makeCredentials(
736 + // Matches the vendored StorageClient's own implicit default scope list
737 + // (StorageClient.php:166-167) — every StorageClient construction in this
738 + // file omits `scopes` and gets this same pair; FULL_CONTROL_SCOPE alone
739 + // is narrower and risks a 403 at the OAuth-scope layer, independent of
740 + // and prior to whatever IAM role/permission the service account holds.
741 + ['https://www.googleapis.com/auth/iam', StorageClient::FULL_CONTROL_SCOPE],
742 + $keyArray
743 + );
744 + $httpClient = CredentialsLoader::makeHttpClient($fetcher, [
745 + 'timeout' => 15,
746 + 'connect_timeout' => 5,
747 + ]);
748 +
749 + $url = 'https://storage.googleapis.com/storage/v1/b/' . rawurlencode($this->bucket_name) . '/managedFolders' . $path;
750 +
751 + $options = ['http_errors' => false];
752 + if ($body !== null) {
753 + $options['json'] = $body;
754 + }
755 +
756 + $response = $httpClient->request($method, $url, $options);
757 +
758 + return [
759 + 'status' => $response->getStatusCode(),
760 + 'body' => json_decode((string) $response->getBody(), true),
761 + ];
762 + }
763 +
764 + /**
765 + * Apply (or, with an empty $private_prefix, un-apply) the private-path
766 + * carve-out via GCS Managed Folders.
767 + *
768 + * Google Cloud permanently disallows attaching an IAM Condition to a
769 + * binding whose principal is allUsers, so the previous CEL-conditional
770 + * approach here could never succeed. Managed Folders let a role be
771 + * granted to allUsers scoped to one prefix with no condition at all —
772 + * but the grant is purely additive (it can only add access, never
773 + * restrict it), so exclusion only works because private_path is a
774 + * sibling of base_path, not nested inside it: the Managed Folder is
775 + * always scoped to base_path (read directly from settings, not derived
776 + * from $private_prefix, which is the *private*-path prefix).
777 + * @since 1.4.1
778 + */
779 + public function applyPrivatePathPolicy($private_prefix) {
780 + if (!$this->bucket || empty($this->bucket_name)) {
781 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
782 + }
783 +
784 + $base_path = isset($this->settings['base_path']) ? trim($this->settings['base_path'], " \n\r\t\v\x00\/ ") : '';
785 + // Trailing slash: unverified against a live GCS project — Google's own
786 + // managedFolder.insert REST reference shows no trailing slash in its
787 + // examples, while its separate CLI guide uses one. Captured once here and
788 + // reused verbatim (URL-encoded) at every call site below so insert/
789 + // setIamPolicy/delete always address the exact same resource name.
790 + $folder_name = $base_path . '/';
791 +
792 + try {
793 + if (empty($private_prefix)) {
794 + // Disable: restore the bucket-wide public grant FIRST, so there's
795 + // never a window where base_path content has no public grant at
796 + // all — then clean up the now-redundant Managed Folder
797 + // (best-effort, not security-critical: the grant that actually
798 + // matters is already restored by the time this runs).
799 + $this->restore_bucket_level_grant();
800 +
801 + if (!empty($base_path)) {
802 + $delete = $this->managed_folder_iam_request('DELETE', '/' . rawurlencode($folder_name) . '?allowNonEmpty=true');
803 + if ($delete['status'] >= 300 && $delete['status'] !== 404) {
804 + error_log('Media Cloud Sync: failed to delete the GCS Managed Folder for base_path while disabling private media — ' . wp_json_encode($delete['body']));
805 + }
806 + }
807 +
808 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Policy removed successfully', 'media-cloud-sync')];
809 + }
810 +
811 + if (empty($base_path)) {
812 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Google Cloud Storage private media requires a base path — enable it in Storage Settings first.', 'media-cloud-sync')];
813 + }
814 +
815 + // Uniform Bucket-Level Access and Public Access Prevention need a live
816 + // $bucket->info() call, which is why these checks live here rather than
817 + // in ProPrivateMedia::apply_policy() (which only has settings, not the
818 + // bucket) — the enable_base_path / outside-base_path checks that DON'T
819 + // need a live call already ran there, before this method was reached.
820 + $info = $this->bucket->info();
821 + $iamConfig = isset($info['iamConfiguration']) ? $info['iamConfiguration'] : [];
822 + $ublaEnabled = !empty($iamConfig['uniformBucketLevelAccess']['enabled']);
823 + $pap = isset($iamConfig['publicAccessPrevention']) ? $iamConfig['publicAccessPrevention'] : 'inherited';
824 +
825 + if (!$ublaEnabled) {
826 + return ['success' => false, 'code' => 200, 'message' => esc_html__("This bucket doesn't have Uniform Bucket-Level Access enabled — enable it in your Google Cloud Storage bucket settings first.", 'media-cloud-sync')];
827 + }
828 + if ($pap === 'enforced') {
829 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Public Access Prevention is enabled for this bucket — disable it first in Bucket Security, since it blocks the public side of this feature too.', 'media-cloud-sync')];
830 + }
831 +
832 + // Enable, in an order that never leaves a window with no public access:
833 + // create + set the Managed Folder's grant first (purely additive — safe
834 + // to briefly overlap with the still-present bucket-wide grant), only
835 + // then drop the bucket-wide grant.
836 + $insert = $this->managed_folder_iam_request('POST', '', ['name' => $folder_name]);
837 + if ($insert['status'] >= 300 && $insert['status'] !== 409) {
838 + $message = isset($insert['body']['error']['message']) ? $insert['body']['error']['message'] : esc_html__('Failed to create the Managed Folder for your base path.', 'media-cloud-sync');
839 + return ['success' => false, 'code' => 200, 'message' => $message];
840 + }
841 +
842 + $setIam = $this->managed_folder_iam_request('PUT', '/' . rawurlencode($folder_name) . '/iam', [
843 + 'bindings' => [
844 + [
845 + 'role' => 'roles/storage.objectViewer',
846 + 'members' => ['allUsers'],
847 + ],
848 + ],
849 + ]);
850 + if ($setIam['status'] >= 300) {
851 + $message = isset($setIam['body']['error']['message']) ? $setIam['body']['error']['message'] : esc_html__('Failed to grant public access on the Managed Folder.', 'media-cloud-sync');
852 + return ['success' => false, 'code' => 200, 'message' => $message];
853 + }
854 +
855 + // Only once the Managed Folder grant is confirmed in effect (both calls
856 + // above succeeded): drop the bucket-wide grant so nothing is public
857 + // bucket-wide anymore. If either call above failed, we stop before this
858 + // line — the bucket is left exactly as it was (bucket-level grant still
859 + // in place, no Managed Folder actively granting anything since its IAM
860 + // policy was never successfully set), a safe, easily-retried state.
861 + $this->drop_bucket_level_grant();
862 +
863 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Policy applied successfully', 'media-cloud-sync')];
864 + } catch (ServiceException $e) {
865 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
866 + } catch (Exception $e) {
867 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
868 + }
869 + }
870 +
871 + /**
872 + * Read the bucket's Public Access Prevention state — GCS's closest
873 + * analog to S3's Block Public Access. Built from a fresh StorageClient/
874 + * Bucket from the passed params (not $this->gcloudClient/$this->bucket)
875 + * so this works during initial setup in the Configure wizard, before
876 + * the connection being configured is the saved/active one — matching
877 + * S3's own getBucketSecuritySettings() pattern.
878 + * @since 1.4.1
879 + */
880 + public function getBucketSecuritySettings($config = [], $bucketConfig = []) {
881 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
882 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
883 +
884 + if (empty($config_json) || empty($bucket_name) || !Utils::is_json($config_json)) {
885 + return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
886 + }
887 +
888 + try {
889 + $keyArray = json_decode($config_json, true);
890 + if (!is_array($keyArray)) {
891 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
892 + }
893 +
894 + $client = new StorageClient(['keyFile' => $keyArray]);
895 + $bucket = $client->bucket($bucket_name);
896 + $info = $bucket->info();
897 + $pap = isset($info['iamConfiguration']['publicAccessPrevention']) ? $info['iamConfiguration']['publicAccessPrevention'] : 'inherited';
898 +
899 + $security = ['block_public_access' => $pap === 'enforced'];
900 +
901 + return ['message' => '', 'code' => 200, 'success' => true, 'security' => $security];
902 + } catch (ServiceException $e) {
903 + return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
904 + } catch (Exception $e) {
905 + return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
906 + }
907 + }
908 +
909 + /**
910 + * Set the bucket's Public Access Prevention state. Built from a fresh
911 + * StorageClient/Bucket from the passed params — same reasoning as
912 + * getBucketSecuritySettings() above. No changeObjectOwnership()
913 + * equivalent here — GCS has no matching concept; the generic dispatcher
914 + * simply hides that field via method_exists() when it's undefined.
915 + * @since 1.4.1
916 + */
917 + public function changePublicAccess($config = [], $bucketConfig = [], $value = false) {
918 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
919 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
920 +
921 + if (empty($config_json) || empty($bucket_name) || !Utils::is_json($config_json)) {
922 + return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
923 + }
924 +
925 + try {
926 + $keyArray = json_decode($config_json, true);
927 + if (!is_array($keyArray)) {
928 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
929 + }
930 +
931 + $client = new StorageClient(['keyFile' => $keyArray]);
932 + $bucket = $client->bucket($bucket_name);
933 + $bucket->update([
934 + 'iamConfiguration' => [
935 + 'publicAccessPrevention' => $value ? 'enforced' : 'inherited',
936 + ],
937 + ]);
938 +
939 + return ['message' => '', 'code' => 200, 'success' => true];
940 + } catch (ServiceException $e) {
941 + return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
942 + } catch (Exception $e) {
943 + return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
944 + }
945 + }
946 +
947 +
948 + /**
949 + * Check the object exist
573 950 * @since 1.1.8
574 951 */
575 - public function exists($key) {
952 + public function exists($key, $bucket = null) {
576 953 if(!$key) return false;
577 954
578 955 try {
579 - $object = $this->bucket->object($key);
956 + $bucket = $bucket ?? $this->bucket;
957 + $object = $bucket->object($key);
580 958 if ($object->exists()) {
581 959 return true;
582 960 } else {
583 961 return false;
@@ -594,116 +972,154 @@
594 972 }
595 973
596 974
597 975 /**
976 + * List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
977 + * resultLimit=$maxKeys caps the iterator to this page only (Bucket::objects() would otherwise auto-paginate the whole bucket).
978 + * @since 1.3.13
979 + */
980 + public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
981 + if (!$this->bucket) {
982 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
983 + }
984 + try {
985 + $options = [
986 + 'maxResults' => $maxKeys,
987 + 'resultLimit' => $maxKeys,
988 + ];
989 + if (!empty($delimiter)) {
990 + $options['delimiter'] = $delimiter;
991 + }
992 + if (!empty($prefix)) {
993 + $options['prefix'] = $prefix;
994 + }
995 + if (!empty($continuationToken)) {
996 + $options['pageToken'] = $continuationToken;
997 + }
998 +
999 + $iterator = $this->bucket->objects($options);
1000 +
1001 + $objects = [];
1002 + foreach ($iterator as $object) {
1003 + $key = $object->name();
1004 + if ($key === $prefix) {
1005 + continue; // the folder placeholder object itself, not a file
1006 + }
1007 + $info = $object->info();
1008 + $objects[] = [
1009 + 'key' => $key,
1010 + 'size' => isset($info['size']) ? (int) $info['size'] : 0,
1011 + 'last_modified' => isset($info['updated']) ? $info['updated'] : '',
1012 + ];
1013 + }
1014 +
1015 + return [
1016 + 'success' => true,
1017 + 'code' => 200,
1018 + 'message' => '',
1019 + 'folders' => $iterator->prefixes(),
1020 + 'objects' => $objects,
1021 + 'next_token' => $iterator->nextResultToken(),
1022 + ];
1023 + } catch (ServiceException $e) {
1024 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
1025 + } catch (Exception $e) {
1026 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
1027 + }
1028 + }
1029 +
1030 + /**
598 1031 * Upload Single
599 1032 * @since 1.0.0
600 1033 * @return boolean
601 1034 */
602 - public function uploadSingle($media_absolute_path, $media_path, $prefix=''){
603 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
604 1036 if (
605 - isset($media_absolute_path) && !empty($media_absolute_path) &&
606 - isset($media_path) && !empty($media_path)
1037 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1038 + isset($relative_source_path) && !empty($relative_source_path)
607 1039 ) {
608 - $file_name = wp_basename( $media_path );
1040 + $file_name = wp_basename( $relative_source_path );
609 1041 if ($file_name) {
610 - $upload_path = Utils::generate_object_key($media_path, $prefix);
1042 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
1043 + if ($upload_path === false) {
1044 + return [
1045 + 'success' => false,
1046 + 'code' => 200,
1047 + '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')
1048 + ];
1049 + }
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
1051 + }
1052 + return [
1053 + 'success' => false,
1054 + 'code' => 200,
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
1056 + ];
1057 + }
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
1062 + ];
1063 + }
611 1064
612 - // Decide Multipart upload or normal put object
613 - if (filesize($media_absolute_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
614 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
615 - try {
1065 + /**
1066 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
1067 + * @since 1.4.0
1068 + */
1069 + public function uploadObjectAtKey($absolute_source_path, $key) {
1070 + return $this->execute_upload($absolute_source_path, $key);
1071 + }
616 1072
617 - $upload = $this->bucket->upload(
618 - fopen($media_absolute_path, 'r'),
619 - [
620 - 'name' => $upload_path,
621 - ]
622 - );
1073 + // Chunked upload above GCLOUD_MULTIPART_MIN_FILE_SIZE, single request below it — same
1074 + // threshold uploadSingle() always used, now shared with uploadObjectAtKey().
1075 + private function execute_upload($absolute_source_path, $key) {
1076 + $options = ['name' => $key];
1077 + if (filesize($absolute_source_path) > Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
1078 + $options['chunkSize'] = 262144 * 2;
1079 + }
1080 + $cache_control = Utils::get_cache_control_header();
1081 + if ($cache_control) {
1082 + $options['cacheControl'] = $cache_control;
1083 + }
623 1084
624 - $object = $this->bucket->object($upload_path);
1085 + try {
1086 + $handle = fopen($absolute_source_path, 'rb');
1087 + $upload = $this->bucket->upload($handle, $options);
625 1088
626 - if ($object->exists()) {
627 - $result = array(
628 - 'success' => true,
629 - 'code' => 200,
630 - 'file_url' => $this->generate_file_url($upload_path),
631 - 'key' => $upload_path,
632 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
633 - );
634 - } else {
635 - $result = array(
636 - 'success' => false,
637 - 'code' => 200,
638 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
639 - );
640 - }
641 - } catch (Exception $e) {
642 - $result = array(
643 - 'success' => false,
644 - 'code' => 200,
645 - 'message' => $e->getMessage(),
646 - );
647 - }
648 - } else {
649 - try {
650 - $upload = $this->bucket->upload(
651 - fopen($media_absolute_path, 'r'),
652 - [
653 - 'name' => $upload_path,
654 - 'chunkSize' => 262144 * 2,
655 - ]
656 - );
657 -
658 - $object = $this->bucket->object($upload_path);
659 -
660 - if ($object->exists()) {
661 - $result = array(
662 - 'success' => true,
663 - 'code' => 200,
664 - 'file_url' => $this->generate_file_url($upload_path),
665 - 'key' => $upload_path,
666 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
667 - );
668 - } else {
669 - $result = array(
670 - 'success' => false,
671 - 'code' => 200,
672 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
673 - );
674 - }
675 - } catch (Exception $e) {
676 - $result = array(
677 - 'success' => false,
678 - 'code' => 200,
679 - 'message' => $e->getMessage(),
680 - );
681 - }
682 - }
683 - } else {
684 - $result = array(
685 - 'success' => false,
686 - 'code' => 200,
687 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
688 - );
1089 + if ($upload->exists()) {
1090 + return [
1091 + 'success' => true,
1092 + 'code' => 200,
1093 + 'file_url' => $this->generate_file_url($key),
1094 + 'key' => $key,
1095 + 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
1096 + ];
689 1097 }
690 - } else {
691 - $result = array(
692 - 'success' => false,
693 - 'code' => 200,
694 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
695 - );
1098 + return [
1099 + 'success' => false,
1100 + 'code' => 200,
1101 + 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
1102 + ];
1103 + } catch (Exception $e) {
1104 + return [
1105 + 'success' => false,
1106 + 'code' => 200,
1107 + 'message' => $e->getMessage(),
1108 + ];
1109 + } finally {
1110 + if (isset($handle) && is_resource($handle)) {
1111 + fclose($handle);
1112 + }
696 1113 }
697 - return $result;
698 1114 }
699 1115
700 -
701 1116 /**
702 1117 * Save object to server
703 1118 * @since 1.0.0
704 1119 */
705 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
706 1122 try {
707 1123 $object = $this->bucket->object($key);
708 1124 if ($object->exists()) {
709 1125 $object->downloadToFile($save_path);
@@ -716,9 +1132,143 @@
716 1132 }
717 1133 return false;
718 1134 }
719 1135
1136 + /**
1137 + * Object bytes in memory, no local file — for callers (e.g. zip download) that need
1138 + * the content itself rather than a copy on the server's filesystem.
1139 + * @since 1.3.13
1140 + */
1141 + public function get_object_content($key) {
1142 + if(!$this->bucket) return false;
1143 + try {
1144 + $object = $this->bucket->object($key);
1145 + if ($object->exists()) {
1146 + return $object->downloadAsString();
1147 + }
1148 + } catch (Exception $e) {
1149 + return false;
1150 + }
1151 + return false;
1152 + }
720 1153
1154 + /**
1155 + * Deletes the live generation, then best-effort purges every prior generation too — a
1156 + * bucket with Object Versioning enabled otherwise keeps old generations (and the storage
1157 + * they use) around at the old key. The live delete happens unconditionally first, in its
1158 + * own try/catch, so the object still ends up gone even if the generation-listing call
1159 + * below fails for any reason.
1160 + * @since 1.3.14
1161 + */
1162 + public function purge_all_versions($key) {
1163 + if (!$this->bucket) {
1164 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
1165 + }
1166 +
1167 + try {
1168 + $this->bucket->object($key)->delete();
1169 + } catch (ServiceException $e) {
1170 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1171 + } catch (\Exception $e) {
1172 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1173 + }
1174 +
1175 + // Best-effort only from here — the live copy above is already gone regardless of
1176 + // whether this bucket has Object Versioning enabled or this call succeeds.
1177 + try {
1178 + foreach ($this->bucket->objects(['prefix' => $key, 'versions' => true]) as $object) {
1179 + if ($object->name() === $key) {
1180 + $object->delete();
1181 + }
1182 + }
1183 + } catch (ServiceException $e) {
1184 + // Generation history cleanup failed — not fatal, live object is gone.
1185 + } catch (\Exception $e) {
1186 + // Generation history cleanup failed — not fatal, live object is gone.
1187 + }
1188 +
1189 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
1190 + }
1191 +
1192 +
1193 + /**
1194 + * Copy an object to a new path in Google Cloud Storage
1195 + *
1196 + * @param string $key Original object key (path in bucket)
1197 + * @param string $new_path Destination object key
1198 + * @return bool True if object was copied successfully, false otherwise
1199 + * @since 1.3.4
1200 + */
1201 + // Trusts copy()'s own success/failure rather than pre/post-verifying with extra
1202 + // exists() calls — each one is a full network round-trip, and with move/copy processing
1203 + // keys sequentially, extra round-trips per file add up fast on a folder with many files.
1204 + // copy() itself throws (caught below) if the source is missing or the copy otherwise
1205 + // fails, so nothing is lost by not checking first.
1206 + public function copy_to_new_path($key, $new_path) {
1207 + if (!$this->bucket) {
1208 + return [
1209 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1210 + 'code' => 200,
1211 + 'success' => false
1212 + ];
1213 + }
1214 + try {
1215 + $sourceObject = $this->bucket->object($key);
1216 + $sourceObject->copy($this->bucket, ['name' => $new_path]);
1217 + return [
1218 + 'success' => true,
1219 + 'code' => 200,
1220 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1221 + ];
1222 + } catch (ServiceException $e) {
1223 + return [
1224 + 'success' => false,
1225 + 'code' => 200,
1226 + 'message' => $e->getMessage()
1227 + ];
1228 + } catch (\Exception $e) {
1229 + return [
1230 + 'success' => false,
1231 + 'code' => 200,
1232 + 'message' => $e->getMessage()
1233 + ];
1234 + }
1235 + }
1236 +
1237 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
1238 + // access there too, so callers should fall back to download+upload on failure.
1239 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
1240 + if (!$this->bucket || !$this->gcloudClient) {
1241 + return [
1242 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1243 + 'code' => 200,
1244 + 'success' => false
1245 + ];
1246 + }
1247 + try {
1248 + $sourceObject = $this->bucket->object($key);
1249 + $sourceObject->copy($this->gcloudClient->bucket($dest_bucket), ['name' => $new_key]);
1250 + return [
1251 + 'success' => true,
1252 + 'code' => 200,
1253 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1254 + ];
1255 + } catch (ServiceException $e) {
1256 + return [
1257 + 'success' => false,
1258 + 'code' => 200,
1259 + 'message' => $e->getMessage()
1260 + ];
1261 + } catch (\Exception $e) {
1262 + return [
1263 + 'success' => false,
1264 + 'code' => 200,
1265 + 'message' => $e->getMessage()
1266 + ];
1267 + }
1268 + }
1269 +
1270 +
721 1271 /**
722 1272 * Delete Single
723 1273 * @since 1.0.0
724 1274 * @return boolean
@@ -724,8 +1274,15 @@
724 1274 * @return boolean
725 1275 */
726 1276 public function deleteSingle($key){
727 1277 $result = array();
1278 + if (!$this->bucket) {
1279 + return array(
1280 + 'success' => false,
1281 + 'code' => 200,
1282 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1283 + );
1284 + }
728 1285 if (isset($key) && !empty($key)) {
729 1286 try {
730 1287 $object = $this->bucket->object($key);
731 1288 $object->delete();
@@ -761,34 +1318,41 @@
761 1318 }
762 1319
763 1320
764 1321 /**
765 - * get presigned URL
1322 + * get private URL
766 1323 * @since 1.0.0
767 1324 * @return boolean
768 1325 */
769 - public function get_presigned_url($key) {
1326 + public function get_private_url($key) {
770 1327 $result = array();
1328 + if (!$this->bucket) {
1329 + return array(
1330 + 'success' => false,
1331 + 'code' => 200,
1332 + 'message' => esc_html__('Client not configured', 'media-cloud-sync')
1333 + );
1334 + }
771 1335 if (isset($key) && !empty($key)) {
772 1336 try {
773 1337 $object = $this->bucket->object($key);
774 1338
775 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1339 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
776 1340
777 - $presignedUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
1341 + $privateUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
778 1342
779 - if ($presignedUrl) {
1343 + if ($privateUrl) {
780 1344 $result = array(
781 1345 'success' => true,
782 1346 'code' => 200,
783 - 'file_url' => $presignedUrl,
784 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync'),
1347 + 'file_url' => $privateUrl,
1348 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync'),
785 1349 );
786 1350 } else {
787 1351 $result = array(
788 1352 'success' => false,
789 1353 'code' => 200,
790 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync'),
1354 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync'),
791 1355 );
792 1356 }
793 1357 } catch (Exception $e) {
794 1358 $result = array(
@@ -810,9 +1374,9 @@
810 1374
811 1375 /**
812 1376 * Generate file URL
813 1377 */
814 - private function generate_file_url($key){
1378 + public function generate_file_url($key){
815 1379 $domain = $this->get_domain();
816 1380
817 1381 return apply_filters('wpmcs_generate_google_file_url',
818 1382 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -820,8 +1384,16 @@
820 1384 $this->bucket_name
821 1385 );
822 1386 }
823 1387
1388 + /**
1389 + * Is provider URL
1390 + * @since 1.3.6
1391 + */
1392 + public function is_provider_url($url) {
1393 + $domain = $this->get_domain();
1394 + return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
1395 + }
824 1396
825 1397 /**
826 1398 * Get domain URL
827 1399 */