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 -155 1.3.111.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,9 +485,9 @@
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 491 return [
490 492 'message' => $result['message'],
491 493 'code' => 200,
@@ -494,9 +496,9 @@
494 496 ];
495 497 }
496 498
497 499 try {
498 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
500 + $object_key = Utils::get_permission_check_object_key();
499 501
500 502 // Check if the object was created successfully
501 503 if (!$this->exists($object_key)) {
502 504 // Create a dummy object to check write permission
@@ -506,13 +508,11 @@
506 508 $this->bucket->upload(
507 509 $stream,
508 510 [
509 511 'name' => $object_key,
512 + 'metadata' => ['cacheControl' => 'no-cache, no-store, must-revalidate'],
510 513 ]
511 514 );
512 - if (is_resource($stream)) {
513 - fclose($stream);
514 - }
515 515 // Re-check if the object was created successfully
516 516 if (!$this->exists($object_key)) {
517 517 $result['status'] = false;
518 518 $result['message'] = esc_html__('Failed to create an object for read permission check, please check service configuration', 'media-cloud-sync');
@@ -527,24 +527,30 @@
527 527
528 528 $url = $this->generate_file_url($object_key);
529 529 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
530 530
531 - $headers = @get_headers($cdn_url);
532 - 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) {
533 540 $result['status'] = true;
534 541 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
535 - } else if (strpos($headers[0], '403') !== false) {
542 + } else if ($status_code === 403) {
536 543 $result['status'] = false;
537 - if($this->cdnConfig['service'] == $this->service) {
544 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
538 545 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
539 546 } else {
540 547 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
541 548 }
542 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
543 - } else if (strpos($headers[0], '404') !== false) {
549 + } else if ($status_code === 404) {
544 550 $result['status'] = false;
545 551 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
546 - } else if (strpos($headers[0], '500') !== false) {
552 + } else if ($status_code === 500) {
547 553 $result['status'] = false;
548 554 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
549 555 } else {
550 556 $result['status'] = false;
@@ -562,8 +568,12 @@
562 568 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
563 569 } catch (Exception $ex) {
564 570 $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
565 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 + }
566 576 }
567 577 }
568 578
569 579 /**
@@ -574,9 +584,9 @@
574 584 if ($this->gcloudClient) {
575 585 try {
576 586 $bucket = $this->gcloudClient->bucket($this->token . '_dummy-bucket-for-auth-check');
577 587 $exists = $bucket->exists(); // Triggers the API call
578 - return false;
588 + return true;
579 589 } catch (ServiceException $e) {
580 590 $statusCode = $e->getCode();
581 591
582 592 $validErrors = [200, 403, 404];
@@ -599,8 +609,9 @@
599 609 *
600 610 */
601 611 public function toPrivate($key) {
602 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
603 614
604 615 try {
605 616 $object = $this->bucket->object($key);
606 617 if ($object->exists()) {
@@ -620,12 +631,13 @@
620 631
621 632 /**
622 633 * Make Object Public
623 634 * @since 1.0.0
624 - *
635 + *
625 636 */
626 637 public function toPublic($key) {
627 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
628 640
629 641 try {
630 642 $object = $this->bucket->object($key);
631 643 if ($object->exists()) {
@@ -641,11 +653,301 @@
641 653 return false;
642 654 }
643 655 }
644 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]);
645 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 +
646 686 /**
647 - * 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
648 950 * @since 1.1.8
649 951 */
650 952 public function exists($key, $bucket = null) {
651 953 if(!$key) return false;
@@ -670,14 +972,68 @@
670 972 }
671 973
672 974
673 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 + /**
674 1031 * Upload Single
675 1032 * @since 1.0.0
676 1033 * @return boolean
677 1034 */
678 - public function uploadSingle($absolute_source_path, $relative_source_path, $prefix=''){
679 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
680 1036 if (
681 1037 isset($absolute_source_path) && !empty($absolute_source_path) &&
682 1038 isset($relative_source_path) && !empty($relative_source_path)
683 1039 ) {
@@ -682,104 +1038,88 @@
682 1038 isset($relative_source_path) && !empty($relative_source_path)
683 1039 ) {
684 1040 $file_name = wp_basename( $relative_source_path );
685 1041 if ($file_name) {
686 - $upload_path = Utils::generate_object_key($relative_source_path, $prefix);
687 -
688 - // Decide Multipart upload or normal put object
689 - if (filesize($absolute_source_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
690 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
691 - try {
692 - $handle = fopen($absolute_source_path, 'rb');
693 - $upload = $this->bucket->upload(
694 - $handle, [ 'name' => $upload_path ]
695 - );
696 - if (is_resource($handle)) {
697 - fclose($handle);
698 - }
699 -
700 - if ($upload->exists()) {
701 - $result = array(
702 - 'success' => true,
703 - 'code' => 200,
704 - 'file_url' => $this->generate_file_url($upload_path),
705 - 'key' => $upload_path,
706 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
707 - );
708 - } else {
709 - $result = array(
710 - 'success' => false,
711 - 'code' => 200,
712 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
713 - );
714 - }
715 - } catch (Exception $e) {
716 - $result = array(
717 - 'success' => false,
718 - 'code' => 200,
719 - 'message' => $e->getMessage(),
720 - );
721 - }
722 - } else {
723 - try {
724 - $handle = fopen($absolute_source_path, 'rb');
725 - $upload = $this->bucket->upload(
726 - $handle,
727 - [
728 - 'name' => $upload_path,
729 - 'chunkSize' => 262144 * 2,
730 - ]
731 - );
732 - if (is_resource($handle)) {
733 - fclose($handle);
734 - }
735 -
736 - if ($upload->exists()) {
737 - $result = array(
738 - 'success' => true,
739 - 'code' => 200,
740 - 'file_url' => $this->generate_file_url($upload_path),
741 - 'key' => $upload_path,
742 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
743 - );
744 - } else {
745 - $result = array(
746 - 'success' => false,
747 - 'code' => 200,
748 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
749 - );
750 - }
751 - } catch (Exception $e) {
752 - $result = array(
753 - 'success' => false,
754 - 'code' => 200,
755 - 'message' => $e->getMessage(),
756 - );
757 - }
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 + ];
758 1049 }
759 - } else {
760 - $result = array(
761 - 'success' => false,
762 - 'code' => 200,
763 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
764 - );
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
765 1051 }
766 - } else {
767 - $result = array(
1052 + return [
768 1053 'success' => false,
769 1054 'code' => 200,
770 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
771 - );
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
1056 + ];
772 1057 }
773 - return $result;
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
1062 + ];
774 1063 }
775 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 + }
776 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 +
777 1116 /**
778 1117 * Save object to server
779 1118 * @since 1.0.0
780 1119 */
781 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
782 1122 try {
783 1123 $object = $this->bucket->object($key);
784 1124 if ($object->exists()) {
785 1125 $object->downloadToFile($save_path);
@@ -792,10 +1132,66 @@
792 1132 }
793 1133 return false;
794 1134 }
795 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 + }
796 1153
797 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 + /**
798 1194 * Copy an object to a new path in Google Cloud Storage
799 1195 *
800 1196 * @param string $key Original object key (path in bucket)
801 1197 * @param string $new_path Destination object key
@@ -801,53 +1197,75 @@
801 1197 * @param string $new_path Destination object key
802 1198 * @return bool True if object was copied successfully, false otherwise
803 1199 * @since 1.3.4
804 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.
805 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 + }
806 1214 try {
807 - // Get the source object
808 1215 $sourceObject = $this->bucket->object($key);
809 - if (!$sourceObject->exists()) {
810 - return [
811 - 'message' => esc_html__('Original file not found' , 'media-cloud-sync'),
812 - 'code' => 200,
813 - 'success' => false
814 - ];
815 - }
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 + }
816 1236
817 - // Step 1: Copy to new path
818 - $destinationObject = $this->bucket->object($new_path);
819 - if (!$destinationObject->exists()) {
820 - $sourceObject->copy($this->bucket, ['name' => $new_path]);
821 - $destinationObject = $this->bucket->object($new_path);
822 - }
823 -
824 - // Step 2: Verify new object exists
825 - if ($destinationObject->exists()) {
826 - return [
827 - 'success' => true,
828 - 'code' => 200,
829 - 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
830 - ];
831 - }
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 + ];
832 1255 } catch (ServiceException $e) {
833 1256 return [
834 1257 'success' => false,
835 - 'code' => 200,
1258 + 'code' => 200,
836 1259 'message' => $e->getMessage()
837 1260 ];
838 1261 } catch (\Exception $e) {
839 1262 return [
840 1263 'success' => false,
841 - 'code' => 200,
1264 + 'code' => 200,
842 1265 'message' => $e->getMessage()
843 1266 ];
844 1267 }
845 - return [
846 - 'success' => false,
847 - 'code' => 200,
848 - 'message' => esc_html__('File not copied', 'media-cloud-sync')
849 - ];
850 1268 }
851 1269
852 1270
853 1271 /**
@@ -856,8 +1274,15 @@
856 1274 * @return boolean
857 1275 */
858 1276 public function deleteSingle($key){
859 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 + }
860 1285 if (isset($key) && !empty($key)) {
861 1286 try {
862 1287 $object = $this->bucket->object($key);
863 1288 $object->delete();
@@ -899,8 +1324,15 @@
899 1324 * @return boolean
900 1325 */
901 1326 public function get_private_url($key) {
902 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 + }
903 1335 if (isset($key) && !empty($key)) {
904 1336 try {
905 1337 $object = $this->bucket->object($key);
906 1338