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 +587 -160 1.3.101.4.1 View file →
@@ -5,8 +5,9 @@
5 5
6 6 // Libraries
7 7 use Dudlewebs\WPMCS\GCP\Google\Cloud\Storage\StorageClient;
8 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'])
@@ -95,9 +99,9 @@
95 99 * @return boolean
96 100 */
97 101 public function verifyCredentials( $config = [] ){
98 102 $config_json = isset($config['config_json']) ? $config['config_json'] : '';
99 - if (!empty($config_json)) {
103 + if (!Service::has_missing_fields([$config_json])) {
100 104 if(!Utils::is_json($config_json)){
101 105 return [
102 106 'success' => false,
103 107 'code' => 200,
@@ -118,9 +122,13 @@
118 122 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
119 123 ];
120 124 }
121 125
122 -
126 + $result = [
127 + 'success' => false,
128 + 'code' => 200,
129 + 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
130 + ];
123 131
124 132 try {
125 133 $bucket = $googleClient->bucket($this->token . '_dummy-bucket-for-auth-check');
126 134 $exists = $bucket->exists(); // Triggers the API call
@@ -188,9 +196,9 @@
188 196 */
189 197 public function verifyBucketExist( $config = [], $bucketConfig = [] ){
190 198 $config_json = isset($config['config_json']) ? $config['config_json'] : '';
191 199 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
192 - if ( empty($config_json) || empty($bucket_name) ) {
200 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
193 201 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
194 202 }
195 203
196 204 if ( !Utils::is_json( $config_json ) ) {
@@ -233,15 +241,8 @@
233 241 'code' => 200,
234 242 'success' => false,
235 243 ];
236 244 }
237 -
238 -
239 - if($bucket_found) {
240 - return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
241 - } else {
242 - return array('message' => esc_html__("Bucket choosen does not exist / does not have read permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
243 - }
244 245 } catch (Exception $ex) {
245 246 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
246 247 }
247 248 }
@@ -255,9 +256,9 @@
255 256 public function createBucket( $config = [], $bucketConfig = [] ){
256 257 $config_json = isset($config['config_json']) ? $config['config_json'] : '';
257 258 $region = isset($bucketConfig['region']) ? $bucketConfig['region'] : '';
258 259 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
259 - if ( empty($config_json) || empty($region) || empty($bucket_name) ) {
260 + if ( Service::has_missing_fields([$config_json, $region, $bucket_name]) ) {
260 261 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
261 262 }
262 263
263 264 if ( !Utils::is_json( $config_json ) ) {
@@ -335,9 +336,9 @@
335 336 'success' => true,
336 337 ];
337 338
338 339 } catch (Exception $ex) {
339 - 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];
340 341 }
341 342 }
342 343
343 344 /**
@@ -346,9 +347,9 @@
346 347 */
347 348 public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
348 349 $config_json = isset($config['config_json']) ? $config['config_json'] : '';
349 350 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
350 - if ( empty($config_json) || empty($bucket_name) ) {
351 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
351 352 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
352 353 }
353 354
354 355 if ( !Utils::is_json( $config_json ) ) {
@@ -374,9 +375,9 @@
374 375 } else {
375 376 return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
376 377 }
377 378 if ($bucket_found) {
378 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
379 + $object_key = Utils::get_permission_check_object_key();
379 380
380 381 // Prepare a temporary file with content to check write permission
381 382 $stream = fopen('php://temp', 'r+');
382 383 fwrite($stream, 'This is a test object to check write permission.');
@@ -388,14 +389,11 @@
388 389 [
389 390 'name' => $object_key,
390 391 ]
391 392 );
392 - if(is_resource($stream)) {
393 - fclose($stream);
394 - }
395 393
396 394 if ($object->exists()) {
397 - 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];
398 396 } else {
399 397 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
400 398 }
401 399 }
@@ -400,8 +398,12 @@
400 398 }
401 399 }
402 400 } catch (Exception $ex) {
403 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 + }
404 406 }
405 407 }
406 408
407 409 /**
@@ -411,9 +413,9 @@
411 413 public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
412 414 $config_json = isset($config['config_json']) ? $config['config_json'] : '';
413 415 $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
414 416
415 - if ( empty($config_json) || empty($bucket_name) ) {
417 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
416 418 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
417 419 }
418 420 if( !Utils::is_json( $config_json ) ) {
419 421 return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
@@ -434,9 +436,9 @@
434 436 }
435 437
436 438 $bucket = $googleClient->bucket($bucket_name);
437 439 try {
438 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
440 + $object_key = Utils::get_permission_check_object_key();
439 441
440 442 // Try fetching a dummy object to test access
441 443 $object = $bucket->object($object_key);
442 444 if ($object->exists()) {
@@ -462,9 +464,9 @@
462 464 ];
463 465 }
464 466 } catch (Exception $ex) {
465 467 return [
466 - '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(),
467 469 'code' => 200,
468 470 'success' => false,
469 471 ];
470 472 }
@@ -483,11 +485,10 @@
483 485 'status' => false,
484 486 'message' => '',
485 487 'lastChecked' => time(),
486 488 ];
487 - if (empty($this->gcloudClient) || empty($this->bucket_name)) {
489 + if (Service::has_missing_fields([$this->gcloudClient, $this->bucket_name])) {
488 490 $result['message'] = esc_html__('Please check the authorization details', 'media-cloud-sync');
489 - Utils::set_status('cdnRead', $result);
490 491 return [
491 492 'message' => $result['message'],
492 493 'code' => 200,
493 494 'success' => false,
@@ -495,9 +496,9 @@
495 496 ];
496 497 }
497 498
498 499 try {
499 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
500 + $object_key = Utils::get_permission_check_object_key();
500 501
501 502 // Check if the object was created successfully
502 503 if (!$this->exists($object_key)) {
503 504 // Create a dummy object to check write permission
@@ -507,18 +508,15 @@
507 508 $this->bucket->upload(
508 509 $stream,
509 510 [
510 511 'name' => $object_key,
512 + 'metadata' => ['cacheControl' => 'no-cache, no-store, must-revalidate'],
511 513 ]
512 514 );
513 - if (is_resource($stream)) {
514 - fclose($stream);
515 - }
516 515 // Re-check if the object was created successfully
517 516 if (!$this->exists($object_key)) {
518 517 $result['status'] = false;
519 518 $result['message'] = esc_html__('Failed to create an object for read permission check, please check service configuration', 'media-cloud-sync');
520 - Utils::set_status('cdnRead', $result);
521 519 return [
522 520 'message' => $result['message'],
523 521 'code' => 200,
524 522 'success' => false,
@@ -529,24 +527,30 @@
529 527
530 528 $url = $this->generate_file_url($object_key);
531 529 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
532 530
533 - $headers = @get_headers($cdn_url);
534 - 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) {
535 540 $result['status'] = true;
536 541 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
537 - } else if (strpos($headers[0], '403') !== false) {
542 + } else if ($status_code === 403) {
538 543 $result['status'] = false;
539 - if($this->cdnConfig['service'] == $this->service) {
544 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
540 545 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
541 546 } else {
542 547 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
543 548 }
544 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
545 - } else if (strpos($headers[0], '404') !== false) {
549 + } else if ($status_code === 404) {
546 550 $result['status'] = false;
547 551 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
548 - } else if (strpos($headers[0], '500') !== false) {
552 + } else if ($status_code === 500) {
549 553 $result['status'] = false;
550 554 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
551 555 } else {
552 556 $result['status'] = false;
@@ -551,9 +555,8 @@
551 555 } else {
552 556 $result['status'] = false;
553 557 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
554 558 }
555 - Utils::set_status('cdnRead', $result);
556 559 $this->deleteSingle($object_key);
557 560 return [
558 561 'message' => $result['message'],
559 562 'code' => 200,
@@ -561,14 +564,16 @@
561 564 'lastChecked' => $result['lastChecked'],
562 565 ];
563 566 } catch (ServiceException $ex) {
564 567 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
565 - Utils::set_status('cdnRead', $result);
566 568 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
567 569 } catch (Exception $ex) {
568 570 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
569 - Utils::set_status('cdnRead', $result);
570 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 + }
571 576 }
572 577 }
573 578
574 579 /**
@@ -579,9 +584,9 @@
579 584 if ($this->gcloudClient) {
580 585 try {
581 586 $bucket = $this->gcloudClient->bucket($this->token . '_dummy-bucket-for-auth-check');
582 587 $exists = $bucket->exists(); // Triggers the API call
583 - return false;
588 + return true;
584 589 } catch (ServiceException $e) {
585 590 $statusCode = $e->getCode();
586 591
587 592 $validErrors = [200, 403, 404];
@@ -604,8 +609,9 @@
604 609 *
605 610 */
606 611 public function toPrivate($key) {
607 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
608 614
609 615 try {
610 616 $object = $this->bucket->object($key);
611 617 if ($object->exists()) {
@@ -625,12 +631,13 @@
625 631
626 632 /**
627 633 * Make Object Public
628 634 * @since 1.0.0
629 - *
635 + *
630 636 */
631 637 public function toPublic($key) {
632 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
633 640
634 641 try {
635 642 $object = $this->bucket->object($key);
636 643 if ($object->exists()) {
@@ -646,11 +653,301 @@
646 653 return false;
647 654 }
648 655 }
649 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]);
650 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 +
651 686 /**
652 - * 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
653 950 * @since 1.1.8
654 951 */
655 952 public function exists($key, $bucket = null) {
656 953 if(!$key) return false;
@@ -675,14 +972,68 @@
675 972 }
676 973
677 974
678 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 + /**
679 1031 * Upload Single
680 1032 * @since 1.0.0
681 1033 * @return boolean
682 1034 */
683 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix=''){
684 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
685 1036 if (
686 1037 isset($absolute_source_path) && !empty($absolute_source_path) &&
687 1038 isset($relative_source_path) && !empty($relative_source_path)
688 1039 ) {
@@ -687,104 +1038,88 @@
687 1038 isset($relative_source_path) && !empty($relative_source_path)
688 1039 ) {
689 1040 $file_name = wp_basename( $relative_source_path );
690 1041 if ($file_name) {
691 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
692 -
693 - // Decide Multipart upload or normal put object
694 - if (filesize($absolute_source_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
695 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
696 - try {
697 - $handle = fopen($absolute_source_path, 'rb');
698 - $upload = $this->bucket->upload(
699 - $handle, [ 'name' => $upload_path ]
700 - );
701 - if (is_resource($handle)) {
702 - fclose($handle);
703 - }
704 -
705 - if ($upload->exists()) {
706 - $result = array(
707 - 'success' => true,
708 - 'code' => 200,
709 - 'file_url' => $this->generate_file_url($upload_path),
710 - 'key' => $upload_path,
711 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
712 - );
713 - } else {
714 - $result = array(
715 - 'success' => false,
716 - 'code' => 200,
717 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
718 - );
719 - }
720 - } catch (Exception $e) {
721 - $result = array(
722 - 'success' => false,
723 - 'code' => 200,
724 - 'message' => $e->getMessage(),
725 - );
726 - }
727 - } else {
728 - try {
729 - $handle = fopen($absolute_source_path, 'rb');
730 - $upload = $this->bucket->upload(
731 - $handle,
732 - [
733 - 'name' => $upload_path,
734 - 'chunkSize' => 262144 * 2,
735 - ]
736 - );
737 - if (is_resource($handle)) {
738 - fclose($handle);
739 - }
740 -
741 - if ($upload->exists()) {
742 - $result = array(
743 - 'success' => true,
744 - 'code' => 200,
745 - 'file_url' => $this->generate_file_url($upload_path),
746 - 'key' => $upload_path,
747 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
748 - );
749 - } else {
750 - $result = array(
751 - 'success' => false,
752 - 'code' => 200,
753 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
754 - );
755 - }
756 - } catch (Exception $e) {
757 - $result = array(
758 - 'success' => false,
759 - 'code' => 200,
760 - 'message' => $e->getMessage(),
761 - );
762 - }
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 + ];
763 1049 }
764 - } else {
765 - $result = array(
766 - 'success' => false,
767 - 'code' => 200,
768 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
769 - );
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
770 1051 }
771 - } else {
772 - $result = array(
1052 + return [
773 1053 'success' => false,
774 1054 'code' => 200,
775 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
776 - );
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
1056 + ];
777 1057 }
778 - return $result;
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
1062 + ];
779 1063 }
780 1064
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 + }
781 1072
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 + }
1084 +
1085 + try {
1086 + $handle = fopen($absolute_source_path, 'rb');
1087 + $upload = $this->bucket->upload($handle, $options);
1088 +
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 + ];
1097 + }
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 + }
1113 + }
1114 + }
1115 +
782 1116 /**
783 1117 * Save object to server
784 1118 * @since 1.0.0
785 1119 */
786 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
787 1122 try {
788 1123 $object = $this->bucket->object($key);
789 1124 if ($object->exists()) {
790 1125 $object->downloadToFile($save_path);
@@ -797,10 +1132,66 @@
797 1132 }
798 1133 return false;
799 1134 }
800 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 + }
801 1153
802 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 + /**
803 1194 * Copy an object to a new path in Google Cloud Storage
804 1195 *
805 1196 * @param string $key Original object key (path in bucket)
806 1197 * @param string $new_path Destination object key
@@ -806,53 +1197,75 @@
806 1197 * @param string $new_path Destination object key
807 1198 * @return bool True if object was copied successfully, false otherwise
808 1199 * @since 1.3.4
809 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.
810 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 + }
811 1214 try {
812 - // Get the source object
813 1215 $sourceObject = $this->bucket->object($key);
814 - if (!$sourceObject->exists()) {
815 - return [
816 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
817 - 'code' => 200,
818 - 'success' => false
819 - ];
820 - }
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 + }
821 1236
822 - // Step 1: Copy to new path
823 - $destinationObject = $this->bucket->object($new_path);
824 - if (!$destinationObject->exists()) {
825 - $sourceObject->copy($this->bucket, ['name' => $new_path]);
826 - $destinationObject = $this->bucket->object($new_path);
827 - }
828 -
829 - // Step 2: Verify new object exists
830 - if ($destinationObject->exists()) {
831 - return [
832 - 'success' => true,
833 - 'code' => 200,
834 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
835 - ];
836 - }
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 + ];
837 1255 } catch (ServiceException $e) {
838 1256 return [
839 1257 'success' => false,
840 - 'code' => 200,
1258 + 'code' => 200,
841 1259 'message' => $e->getMessage()
842 1260 ];
843 1261 } catch (\Exception $e) {
844 1262 return [
845 1263 'success' => false,
846 - 'code' => 200,
1264 + 'code' => 200,
847 1265 'message' => $e->getMessage()
848 1266 ];
849 1267 }
850 - return [
851 - 'success' => false,
852 - 'code' => 200,
853 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
854 - ];
855 1268 }
856 1269
857 1270
858 1271 /**
@@ -861,8 +1274,15 @@
861 1274 * @return boolean
862 1275 */
863 1276 public function deleteSingle($key){
864 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 + }
865 1285 if (isset($key) && !empty($key)) {
866 1286 try {
867 1287 $object = $this->bucket->object($key);
868 1288 $object->delete();
@@ -904,8 +1324,15 @@
904 1324 * @return boolean
905 1325 */
906 1326 public function get_private_url($key) {
907 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 + }
908 1335 if (isset($key) && !empty($key)) {
909 1336 try {
910 1337 $object = $this->bucket->object($key);
911 1338