assets_url = WPMCS_ASSETS_URL;
$this->version = WPMCS_VERSION;
$this->token = WPMCS_TOKEN;
// Initialize setup
$this->init($credentials);
}
/**
* Initialise Client
*
* @param array|null $credentials Optional explicit credentials; falls back to
* Utils::get_credentials() when omitted.
*/
public function init($credentials = null) {
$this->settings = Utils::get_settings();
$this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
$this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
? $this->credentials['config']
: [];
$this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
? $this->credentials['bucketConfig']
: [];
$this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
? $this->bucketConfig['bucket_name']
: '';
$this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
? $this->credentials['cdn']
: [];
if (
isset($this->config['config_json']) && !empty($this->config['config_json']) &&
isset($this->bucket_name) && !empty($this->bucket_name)
) {
if(Utils::is_json($this->config['config_json'])){
// Set google client
$keyArray = json_decode($this->config['config_json'], true);
if (is_array($keyArray)) {
$this->gcloudClient = new StorageClient([
'keyFile' => $keyArray,
]);
$this->bucket = $this->gcloudClient->bucket($this->bucket_name);
} else {
// Handle JSON decode failure
throw new \Exception('Invalid JSON provided for GCloud credentials.');
}
} else {
add_action('admin_notices', function (){
echo wp_kses_post(sprintf( "
%s:
Google Cloud Storage configuration is invalid.
It may break the media url's as well as media uploads.
Re-configure plugin to fix the issue.
",
esc_html__('Media Cloud Sync', 'media-cloud-sync'),
admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
));
});
}
}
}
/**
* Verify Credentials
* @since 1.0.0
* @return boolean
*/
public function verifyCredentials( $config = [] ){
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
if (!Service::has_missing_fields([$config_json])) {
if(!Utils::is_json($config_json)){
return [
'success' => false,
'code' => 200,
'message' => esc_html__('Invalid JSON configuration, please try again', 'media-cloud-sync'),
];
}
try {
$config_array = json_decode($config_json, true);
if (is_array($config_array)) {
$googleClient = new StorageClient([
'keyFile' => $config_array
]);
} else {
return [
'success' => false,
'code' => 200,
'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
];
}
$result = [
'success' => false,
'code' => 200,
'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
];
try {
$bucket = $googleClient->bucket($this->token . '_dummy-bucket-for-auth-check');
$exists = $bucket->exists(); // Triggers the API call
// If we reach here, the credentials are valid
$result = [
'success' => true,
'code' => 200,
'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
];
} catch (ServiceException $e) {
$statusCode = $e->getCode();
$validErrors = [200, 403, 404];
if (in_array($statusCode, $validErrors)) {
// If we reach here, the credentials are valid
$result = [
'success' => true,
'code' => 200,
'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
];
}
}
if($result['success'] == false) {
return $result;
}
try {
$buckets = $googleClient->buckets();
$newBucketFormat = [];
if(isset($buckets) && !empty($buckets)){
foreach($buckets as $bucket) {
$name = $bucket->name();
if(!empty($name)) {
// Fetch the bucket's metadata
$bucketInfo = $bucket->info();
$newBucketFormat[] = ['Name' => $name, 'CreationDate' => $bucketInfo['timeCreated']];
}
}
}
$result['buckets_data']['buckets'] = $newBucketFormat;
$result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
$result['buckets_data']['status'] = true;
} catch (Exception $e) {
$result ['buckets_data']['buckets'] = [];
$result ['buckets_data']['message'] = esc_html__('Unable to list buckets, Please check the bucket listing permission', 'media-cloud-sync');
$result ['buckets_data']['status'] = false;
}
return $result;
} catch (Exception $ex) {
return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
}
}
return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
}
/**
* Verify Bucket Exists
* @since 1.0.0
* @return boolean
*/
public function verifyBucketExist( $config = [], $bucketConfig = [] ){
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
$bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
if ( !Utils::is_json( $config_json ) ) {
return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
try {
$config_array = json_decode($config_json, true);
if (is_array($config_array)) {
$googleClient = new StorageClient([
'keyFile' => $config_array
]);
} else {
return [
'success' => false,
'code' => 200,
'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
];
}
try {
$bucket = $googleClient->bucket($bucket_name);
if ($bucket->exists()) {
return [
'message' => esc_html__('Bucket exists', 'media-cloud-sync'),
'code' => 200,
'success' => true,
];
} else {
return [
'message' => esc_html__('Bucket does not exist', 'media-cloud-sync'),
'code' => 200,
'success' => false,
];
}
} catch (ServiceException $e) {
return [
'message' => esc_html__('Bucket does not exist or credentials are invalid: ', 'media-cloud-sync') . $e->getMessage(),
'code' => 200,
'success' => false,
];
}
} catch (Exception $ex) {
return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
}
/**
* Create Bucket
* @since 1.0.0
* @return boolean
*/
public function createBucket( $config = [], $bucketConfig = [] ){
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
$region = isset($bucketConfig['region']) ? $bucketConfig['region'] : '';
$bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
if ( Service::has_missing_fields([$config_json, $region, $bucket_name]) ) {
return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
if ( !Utils::is_json( $config_json ) ) {
return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
try {
$config_array = json_decode($config_json, true);
if (is_array($config_array)) {
$googleClient = new StorageClient([
'keyFile' => $config_array
]);
} else {
return [
'success' => false,
'code' => 200,
'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
];
}
// Create Bucket
$bucket = $googleClient->createBucket($bucket_name, [
'location' => $region,
'iamConfiguration' => [
'uniformBucketLevelAccess' => [
'enabled' => true
]
]
]);
// Fetch the bucket's IAM
try {
$iam = $bucket->iam();
$policy = $iam->policy();
// Add allUsers as a Storage Object Viewer
$policy['bindings'][] = [
'role' => 'roles/storage.objectViewer',
'members' => ['allUsers'],
];
// Set the updated policy
$iam->setPolicy($policy);
} catch (ServiceException $e) {
return [
'message' => esc_html__('Bucket created successfully. But failed to set IAM policy.', 'media-cloud-sync'),
'data' => [
'Name' => $bucket_name,
'CreationDate' => date('Y-m-d\TH:i:s\Z'),
],
'code' => 200,
'success' => true,
];
} catch (Exception $e) {
return [
'message' => esc_html__('Bucket created successfully. But failed to set IAM policy.', 'media-cloud-sync'),
'data' => [
'Name' => $bucket_name,
'CreationDate' => date('Y-m-d\TH:i:s\Z'),
],
'code' => 200,
'success' => true,
];
}
return [
'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
'data' => [
'Name' => $bucket_name,
'CreationDate' => date('Y-m-d\TH:i:s\Z'),
],
'code' => 200,
'success' => true,
];
} catch (Exception $ex) {
return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
}
/**
* Check Bucket Write Permission
* @since 1.0.0
*/
public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
$bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
if ( !Utils::is_json( $config_json ) ) {
return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
try {
$config_array = json_decode($config_json, true);
if (is_array($config_array)) {
$googleClient = new StorageClient([
'keyFile' => $config_array
]);
} else {
return [
'success' => false,
'code' => 200,
'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
];
}
$bucket = $googleClient->bucket($bucket_name);
if ($bucket->exists()) {
$bucket_found = true;
} else {
return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
if ($bucket_found) {
$object_key = Utils::get_permission_check_object_key();
// Prepare a temporary file with content to check write permission
$stream = fopen('php://temp', 'r+');
fwrite($stream, 'This is a test object to check write permission.');
rewind($stream);
// Upload the object to the bucket
$object = $bucket->upload(
$stream,
[
'name' => $object_key,
]
);
if ($object->exists()) {
return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
} else {
return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
}
} catch (Exception $ex) {
return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
} finally {
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
}
}
/**
* Check Bucket Delete Permission
* @since 1.0.0
*/
public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
$bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
if( !Utils::is_json( $config_json ) ) {
return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
try {
$config_array = json_decode($config_json, true);
if (is_array($config_array)) {
$googleClient = new StorageClient([
'keyFile' => $config_array
]);
} else {
return [
'success' => false,
'code' => 200,
'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
];
}
$bucket = $googleClient->bucket($bucket_name);
try {
$object_key = Utils::get_permission_check_object_key();
// Try fetching a dummy object to test access
$object = $bucket->object($object_key);
if ($object->exists()) {
$object->delete();
if (!$object->exists()) {
return [
'message' => esc_html__('Bucket exists', 'media-cloud-sync'),
'code' => 200,
'success' => true,
];
} else {
return [
'message' => esc_html__('You do not have permission to delete object', 'media-cloud-sync'),
'code' => 200,
'success' => false,
];
}
} else {
return [
'message' => esc_html__('Object does not exist', 'media-cloud-sync'),
'code' => 200,
'success' => false,
];
}
} catch (Exception $ex) {
return [
'message' => esc_html__('Object does not exist or credentials are invalid: ', 'media-cloud-sync') . $ex->getMessage(),
'code' => 200,
'success' => false,
];
}
} catch (Exception $ex) {
return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
}
/**
* Check Bucket Read Permission
* @since 1.2.4
*/
public function verifyObjectReadPermission() {
$result = [
'status' => false,
'message' => '',
'lastChecked' => time(),
];
if (Service::has_missing_fields([$this->gcloudClient, $this->bucket_name])) {
$result['message'] = esc_html__('Please check the authorization details', 'media-cloud-sync');
return [
'message' => $result['message'],
'code' => 200,
'success' => false,
'lastChecked' => $result['lastChecked'],
];
}
try {
$object_key = Utils::get_permission_check_object_key();
// Check if the object was created successfully
if (!$this->exists($object_key)) {
// Create a dummy object to check write permission
$stream = fopen('php://temp', 'r+');
fwrite($stream, 'This is a test object to check read permission.');
rewind($stream);
$this->bucket->upload(
$stream,
[
'name' => $object_key,
'metadata' => ['cacheControl' => 'no-cache, no-store, must-revalidate'],
]
);
// Re-check if the object was created successfully
if (!$this->exists($object_key)) {
$result['status'] = false;
$result['message'] = esc_html__('Failed to create an object for read permission check, please check service configuration', 'media-cloud-sync');
return [
'message' => $result['message'],
'code' => 200,
'success' => false,
'lastChecked' => $result['lastChecked'],
];
}
}
$url = $this->generate_file_url($object_key);
$cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
// Never trust a cached response for this fixed, predictable URL — a stale cached
// error would otherwise keep failing the check long after real access is fine.
$no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
$headers = @get_headers($cdn_url, false, $no_cache_context);
$status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
? (int) $matches[1]
: 0;
if ($status_code === 200) {
$result['status'] = true;
$result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
} else if ($status_code === 403) {
$result['status'] = false;
if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
$result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
} else {
$result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
}
} else if ($status_code === 404) {
$result['status'] = false;
$result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
} else if ($status_code === 500) {
$result['status'] = false;
$result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
} else {
$result['status'] = false;
$result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
}
$this->deleteSingle($object_key);
return [
'message' => $result['message'],
'code' => 200,
'success' => $result['status'],
'lastChecked' => $result['lastChecked'],
];
} catch (ServiceException $ex) {
$result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
} catch (Exception $ex) {
$result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
} finally {
if (isset($stream) && is_resource($stream)) {
fclose($stream);
}
}
}
/**
* isConfigured Function To Identify the congfigurations are correct
* @since 1.0.0
*/
public function isConfigured(){
if ($this->gcloudClient) {
try {
$bucket = $this->gcloudClient->bucket($this->token . '_dummy-bucket-for-auth-check');
$exists = $bucket->exists(); // Triggers the API call
return true;
} catch (ServiceException $e) {
$statusCode = $e->getCode();
$validErrors = [200, 403, 404];
if (in_array($statusCode, $validErrors)) {
// If we reach here, the credentials are valid
return true;
} else {
// If we reach here, the credentials are not valid
return false;
}
}
}
return false;
}
/**
* Make Object Private
* @since 1.0.0
*
*/
public function toPrivate($key) {
if(!$key) return false;
if(!$this->bucket) return false;
try {
$object = $this->bucket->object($key);
if ($object->exists()) {
$object->update(['acl' => []], ['predefinedAcl' => 'private']);
return true;
}
} catch (ServiceException $e) {
// Handle exception if needed
return false;
} catch (Exception $e) {
// Handle other exceptions if needed
return false;
}
return false;
}
/**
* Make Object Public
* @since 1.0.0
*
*/
public function toPublic($key) {
if(!$key) return false;
if(!$this->bucket) return false;
try {
$object = $this->bucket->object($key);
if ($object->exists()) {
$object->update(['acl' => []], ['predefinedAcl' => 'publicRead']);
return true;
}
return false;
} catch (ServiceException $e) {
// Handle exception if needed
return false;
} catch (Exception $e) {
// Handle other exceptions if needed
return false;
}
}
/**
* Fetch the bucket's IAM policy with the plugin's own
* allUsers:roles/storage.objectViewer binding(s) dropped — shared by
* both drop_bucket_level_grant() and restore_bucket_level_grant() so
* the find-and-drop logic isn't written twice. Every other binding
* (project owners/editors, other service accounts, etc.) is left
* exactly as found, unlike S3 where the whole policy is safely one
* plugin-owned statement.
* @since 1.4.1
*/
private function bucket_policy_without_own_binding() {
$iam = $this->bucket->iam();
$policy = $iam->policy(['requestedPolicyVersion' => 3]);
$bindings = [];
foreach (($policy['bindings'] ?? []) as $binding) {
if (
isset($binding['role'], $binding['members']) &&
$binding['role'] === 'roles/storage.objectViewer' &&
in_array('allUsers', (array) $binding['members'], true)
) {
continue;
}
$bindings[] = $binding;
}
return ['iam' => $iam, 'policy' => $policy, 'bindings' => $bindings];
}
/**
* Drop the plugin's bucket-wide allUsers:objectViewer binding, if any,
* and do not re-add it — used by the enable path, once the
* Managed-Folder-scoped grant is already confirmed in effect.
* @since 1.4.1
*/
private function drop_bucket_level_grant() {
$state = $this->bucket_policy_without_own_binding();
$state['policy']['bindings'] = $state['bindings'];
$state['policy']['version'] = 3;
$state['iam']->setPolicy($state['policy'], ['requestedPolicyVersion' => 3]);
}
/**
* Find-and-drop then re-add exactly one bucket-wide
* allUsers:objectViewer binding — mirrors createBucket()'s original
* grant. Used by the disable path to restore the plugin's original,
* pre-private-media public-access mechanism; find-and-drop-first
* guarantees a repeated apply/remove cycle never accumulates
* duplicate bindings.
* @since 1.4.1
*/
private function restore_bucket_level_grant() {
$state = $this->bucket_policy_without_own_binding();
$state['bindings'][] = [
'role' => 'roles/storage.objectViewer',
'members' => ['allUsers'],
];
$state['policy']['bindings'] = $state['bindings'];
$state['policy']['version'] = 3;
$state['iam']->setPolicy($state['policy'], ['requestedPolicyVersion' => 3]);
}
/**
* Hand-written, authenticated REST call against GCS's Managed Folders
* API (storage/v1/b/{bucket}/managedFolders/...) — the vendored SDK has
* no native class for this resource. Mints a fresh Guzzle client from
* the same service-account JSON already trusted for the ordinary
* StorageClient, since Bucket::$connection/StorageClient::$connection
* have no public accessor into their internal auth machinery.
*
* $http_errors is disabled so 4xx/5xx responses are returned (not
* thrown) — callers need to distinguish e.g. 409 (already exists) and
* 404 (already gone) from genuine failures, which is far cleaner done
* by inspecting the status code than by parsing exception messages.
* @since 1.4.1
*/
private function managed_folder_iam_request($method, $path, $body = null) {
$keyArray = json_decode($this->config['config_json'], true);
$fetcher = CredentialsLoader::makeCredentials(
// Matches the vendored StorageClient's own implicit default scope list
// (StorageClient.php:166-167) — every StorageClient construction in this
// file omits `scopes` and gets this same pair; FULL_CONTROL_SCOPE alone
// is narrower and risks a 403 at the OAuth-scope layer, independent of
// and prior to whatever IAM role/permission the service account holds.
['https://www.googleapis.com/auth/iam', StorageClient::FULL_CONTROL_SCOPE],
$keyArray
);
$httpClient = CredentialsLoader::makeHttpClient($fetcher, [
'timeout' => 15,
'connect_timeout' => 5,
]);
$url = 'https://storage.googleapis.com/storage/v1/b/' . rawurlencode($this->bucket_name) . '/managedFolders' . $path;
$options = ['http_errors' => false];
if ($body !== null) {
$options['json'] = $body;
}
$response = $httpClient->request($method, $url, $options);
return [
'status' => $response->getStatusCode(),
'body' => json_decode((string) $response->getBody(), true),
];
}
/**
* Apply (or, with an empty $private_prefix, un-apply) the private-path
* carve-out via GCS Managed Folders.
*
* Google Cloud permanently disallows attaching an IAM Condition to a
* binding whose principal is allUsers, so the previous CEL-conditional
* approach here could never succeed. Managed Folders let a role be
* granted to allUsers scoped to one prefix with no condition at all —
* but the grant is purely additive (it can only add access, never
* restrict it), so exclusion only works because private_path is a
* sibling of base_path, not nested inside it: the Managed Folder is
* always scoped to base_path (read directly from settings, not derived
* from $private_prefix, which is the *private*-path prefix).
* @since 1.4.1
*/
public function applyPrivatePathPolicy($private_prefix) {
if (!$this->bucket || empty($this->bucket_name)) {
return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
}
$base_path = isset($this->settings['base_path']) ? trim($this->settings['base_path'], " \n\r\t\v\x00\/ ") : '';
// Trailing slash: unverified against a live GCS project — Google's own
// managedFolder.insert REST reference shows no trailing slash in its
// examples, while its separate CLI guide uses one. Captured once here and
// reused verbatim (URL-encoded) at every call site below so insert/
// setIamPolicy/delete always address the exact same resource name.
$folder_name = $base_path . '/';
try {
if (empty($private_prefix)) {
// Disable: restore the bucket-wide public grant FIRST, so there's
// never a window where base_path content has no public grant at
// all — then clean up the now-redundant Managed Folder
// (best-effort, not security-critical: the grant that actually
// matters is already restored by the time this runs).
$this->restore_bucket_level_grant();
if (!empty($base_path)) {
$delete = $this->managed_folder_iam_request('DELETE', '/' . rawurlencode($folder_name) . '?allowNonEmpty=true');
if ($delete['status'] >= 300 && $delete['status'] !== 404) {
error_log('Media Cloud Sync: failed to delete the GCS Managed Folder for base_path while disabling private media — ' . wp_json_encode($delete['body']));
}
}
return ['success' => true, 'code' => 200, 'message' => esc_html__('Policy removed successfully', 'media-cloud-sync')];
}
if (empty($base_path)) {
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')];
}
// Uniform Bucket-Level Access and Public Access Prevention need a live
// $bucket->info() call, which is why these checks live here rather than
// in ProPrivateMedia::apply_policy() (which only has settings, not the
// bucket) — the enable_base_path / outside-base_path checks that DON'T
// need a live call already ran there, before this method was reached.
$info = $this->bucket->info();
$iamConfig = isset($info['iamConfiguration']) ? $info['iamConfiguration'] : [];
$ublaEnabled = !empty($iamConfig['uniformBucketLevelAccess']['enabled']);
$pap = isset($iamConfig['publicAccessPrevention']) ? $iamConfig['publicAccessPrevention'] : 'inherited';
if (!$ublaEnabled) {
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')];
}
if ($pap === 'enforced') {
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')];
}
// Enable, in an order that never leaves a window with no public access:
// create + set the Managed Folder's grant first (purely additive — safe
// to briefly overlap with the still-present bucket-wide grant), only
// then drop the bucket-wide grant.
$insert = $this->managed_folder_iam_request('POST', '', ['name' => $folder_name]);
if ($insert['status'] >= 300 && $insert['status'] !== 409) {
$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');
return ['success' => false, 'code' => 200, 'message' => $message];
}
$setIam = $this->managed_folder_iam_request('PUT', '/' . rawurlencode($folder_name) . '/iam', [
'bindings' => [
[
'role' => 'roles/storage.objectViewer',
'members' => ['allUsers'],
],
],
]);
if ($setIam['status'] >= 300) {
$message = isset($setIam['body']['error']['message']) ? $setIam['body']['error']['message'] : esc_html__('Failed to grant public access on the Managed Folder.', 'media-cloud-sync');
return ['success' => false, 'code' => 200, 'message' => $message];
}
// Only once the Managed Folder grant is confirmed in effect (both calls
// above succeeded): drop the bucket-wide grant so nothing is public
// bucket-wide anymore. If either call above failed, we stop before this
// line — the bucket is left exactly as it was (bucket-level grant still
// in place, no Managed Folder actively granting anything since its IAM
// policy was never successfully set), a safe, easily-retried state.
$this->drop_bucket_level_grant();
return ['success' => true, 'code' => 200, 'message' => esc_html__('Policy applied successfully', 'media-cloud-sync')];
} catch (ServiceException $e) {
return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
} catch (Exception $e) {
return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
}
}
/**
* Read the bucket's Public Access Prevention state — GCS's closest
* analog to S3's Block Public Access. Built from a fresh StorageClient/
* Bucket from the passed params (not $this->gcloudClient/$this->bucket)
* so this works during initial setup in the Configure wizard, before
* the connection being configured is the saved/active one — matching
* S3's own getBucketSecuritySettings() pattern.
* @since 1.4.1
*/
public function getBucketSecuritySettings($config = [], $bucketConfig = []) {
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
$bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
if (empty($config_json) || empty($bucket_name) || !Utils::is_json($config_json)) {
return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
try {
$keyArray = json_decode($config_json, true);
if (!is_array($keyArray)) {
return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
$client = new StorageClient(['keyFile' => $keyArray]);
$bucket = $client->bucket($bucket_name);
$info = $bucket->info();
$pap = isset($info['iamConfiguration']['publicAccessPrevention']) ? $info['iamConfiguration']['publicAccessPrevention'] : 'inherited';
$security = ['block_public_access' => $pap === 'enforced'];
return ['message' => '', 'code' => 200, 'success' => true, 'security' => $security];
} catch (ServiceException $e) {
return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
} catch (Exception $e) {
return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
}
/**
* Set the bucket's Public Access Prevention state. Built from a fresh
* StorageClient/Bucket from the passed params — same reasoning as
* getBucketSecuritySettings() above. No changeObjectOwnership()
* equivalent here — GCS has no matching concept; the generic dispatcher
* simply hides that field via method_exists() when it's undefined.
* @since 1.4.1
*/
public function changePublicAccess($config = [], $bucketConfig = [], $value = false) {
$config_json = isset($config['config_json']) ? $config['config_json'] : '';
$bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
if (empty($config_json) || empty($bucket_name) || !Utils::is_json($config_json)) {
return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
try {
$keyArray = json_decode($config_json, true);
if (!is_array($keyArray)) {
return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
$client = new StorageClient(['keyFile' => $keyArray]);
$bucket = $client->bucket($bucket_name);
$bucket->update([
'iamConfiguration' => [
'publicAccessPrevention' => $value ? 'enforced' : 'inherited',
],
]);
return ['message' => '', 'code' => 200, 'success' => true];
} catch (ServiceException $e) {
return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
} catch (Exception $e) {
return ['message' => $e->getMessage() ?: esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
}
}
/**
* Check the object exist
* @since 1.1.8
*/
public function exists($key, $bucket = null) {
if(!$key) return false;
try {
$bucket = $bucket ?? $this->bucket;
$object = $bucket->object($key);
if ($object->exists()) {
return true;
} else {
return false;
}
} catch (ServiceException $e) {
// Handle exception if needed
return false;
} catch (Exception $e) {
// Handle other exceptions if needed
return false;
}
return false;
}
/**
* List Objects — $delimiter = null gives a flat/recursive listing instead of one folder level.
* resultLimit=$maxKeys caps the iterator to this page only (Bucket::objects() would otherwise auto-paginate the whole bucket).
* @since 1.3.13
*/
public function listObjects($prefix = '', $continuationToken = null, $maxKeys = 1000, $delimiter = '/') {
if (!$this->bucket) {
return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync'), 'folders' => [], 'objects' => [], 'next_token' => null];
}
try {
$options = [
'maxResults' => $maxKeys,
'resultLimit' => $maxKeys,
];
if (!empty($delimiter)) {
$options['delimiter'] = $delimiter;
}
if (!empty($prefix)) {
$options['prefix'] = $prefix;
}
if (!empty($continuationToken)) {
$options['pageToken'] = $continuationToken;
}
$iterator = $this->bucket->objects($options);
$objects = [];
foreach ($iterator as $object) {
$key = $object->name();
if ($key === $prefix) {
continue; // the folder placeholder object itself, not a file
}
$info = $object->info();
$objects[] = [
'key' => $key,
'size' => isset($info['size']) ? (int) $info['size'] : 0,
'last_modified' => isset($info['updated']) ? $info['updated'] : '',
];
}
return [
'success' => true,
'code' => 200,
'message' => '',
'folders' => $iterator->prefixes(),
'objects' => $objects,
'next_token' => $iterator->nextResultToken(),
];
} catch (ServiceException $e) {
return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
} catch (Exception $e) {
return ['success' => false, 'code' => 200, 'message' => $e->getMessage(), 'folders' => [], 'objects' => [], 'next_token' => null];
}
}
/**
* Upload Single
* @since 1.0.0
* @return boolean
*/
public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
if (
isset($absolute_source_path) && !empty($absolute_source_path) &&
isset($relative_source_path) && !empty($relative_source_path)
) {
$file_name = wp_basename( $relative_source_path );
if ($file_name) {
$upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
if ($upload_path === false) {
return [
'success' => false,
'code' => 200,
'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')
];
}
return $this->execute_upload($absolute_source_path, $upload_path);
}
return [
'success' => false,
'code' => 200,
'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
];
}
return [
'success' => false,
'code' => 200,
'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
];
}
/**
* Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
* @since 1.4.0
*/
public function uploadObjectAtKey($absolute_source_path, $key) {
return $this->execute_upload($absolute_source_path, $key);
}
// Chunked upload above GCLOUD_MULTIPART_MIN_FILE_SIZE, single request below it — same
// threshold uploadSingle() always used, now shared with uploadObjectAtKey().
private function execute_upload($absolute_source_path, $key) {
$options = ['name' => $key];
if (filesize($absolute_source_path) > Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
$options['chunkSize'] = 262144 * 2;
}
$cache_control = Utils::get_cache_control_header();
if ($cache_control) {
$options['cacheControl'] = $cache_control;
}
try {
$handle = fopen($absolute_source_path, 'rb');
$upload = $this->bucket->upload($handle, $options);
if ($upload->exists()) {
return [
'success' => true,
'code' => 200,
'file_url' => $this->generate_file_url($key),
'key' => $key,
'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
];
}
return [
'success' => false,
'code' => 200,
'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
];
} catch (Exception $e) {
return [
'success' => false,
'code' => 200,
'message' => $e->getMessage(),
];
} finally {
if (isset($handle) && is_resource($handle)) {
fclose($handle);
}
}
}
/**
* Save object to server
* @since 1.0.0
*/
public function object_to_server($key, $save_path){
if(!$this->bucket) return false;
try {
$object = $this->bucket->object($key);
if ($object->exists()) {
$object->downloadToFile($save_path);
if (file_exists($save_path)) {
return true;
}
}
} catch (Exception $e) {
return false;
}
return false;
}
/**
* Object bytes in memory, no local file — for callers (e.g. zip download) that need
* the content itself rather than a copy on the server's filesystem.
* @since 1.3.13
*/
public function get_object_content($key) {
if(!$this->bucket) return false;
try {
$object = $this->bucket->object($key);
if ($object->exists()) {
return $object->downloadAsString();
}
} catch (Exception $e) {
return false;
}
return false;
}
/**
* Deletes the live generation, then best-effort purges every prior generation too — a
* bucket with Object Versioning enabled otherwise keeps old generations (and the storage
* they use) around at the old key. The live delete happens unconditionally first, in its
* own try/catch, so the object still ends up gone even if the generation-listing call
* below fails for any reason.
* @since 1.3.14
*/
public function purge_all_versions($key) {
if (!$this->bucket) {
return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
}
try {
$this->bucket->object($key)->delete();
} catch (ServiceException $e) {
return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
} catch (\Exception $e) {
return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
}
// Best-effort only from here — the live copy above is already gone regardless of
// whether this bucket has Object Versioning enabled or this call succeeds.
try {
foreach ($this->bucket->objects(['prefix' => $key, 'versions' => true]) as $object) {
if ($object->name() === $key) {
$object->delete();
}
}
} catch (ServiceException $e) {
// Generation history cleanup failed — not fatal, live object is gone.
} catch (\Exception $e) {
// Generation history cleanup failed — not fatal, live object is gone.
}
return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
}
/**
* Copy an object to a new path in Google Cloud Storage
*
* @param string $key Original object key (path in bucket)
* @param string $new_path Destination object key
* @return bool True if object was copied successfully, false otherwise
* @since 1.3.4
*/
// Trusts copy()'s own success/failure rather than pre/post-verifying with extra
// exists() calls — each one is a full network round-trip, and with move/copy processing
// keys sequentially, extra round-trips per file add up fast on a folder with many files.
// copy() itself throws (caught below) if the source is missing or the copy otherwise
// fails, so nothing is lost by not checking first.
public function copy_to_new_path($key, $new_path) {
if (!$this->bucket) {
return [
'message' => esc_html__('Client not configured', 'media-cloud-sync'),
'code' => 200,
'success' => false
];
}
try {
$sourceObject = $this->bucket->object($key);
$sourceObject->copy($this->bucket, ['name' => $new_path]);
return [
'success' => true,
'code' => 200,
'message' => esc_html__('File copied successfully', 'media-cloud-sync')
];
} catch (ServiceException $e) {
return [
'success' => false,
'code' => 200,
'message' => $e->getMessage()
];
} catch (\Exception $e) {
return [
'success' => false,
'code' => 200,
'message' => $e->getMessage()
];
}
}
// Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
// access there too, so callers should fall back to download+upload on failure.
public function copy_to_bucket($key, $new_key, $dest_bucket) {
if (!$this->bucket || !$this->gcloudClient) {
return [
'message' => esc_html__('Client not configured', 'media-cloud-sync'),
'code' => 200,
'success' => false
];
}
try {
$sourceObject = $this->bucket->object($key);
$sourceObject->copy($this->gcloudClient->bucket($dest_bucket), ['name' => $new_key]);
return [
'success' => true,
'code' => 200,
'message' => esc_html__('File copied successfully', 'media-cloud-sync')
];
} catch (ServiceException $e) {
return [
'success' => false,
'code' => 200,
'message' => $e->getMessage()
];
} catch (\Exception $e) {
return [
'success' => false,
'code' => 200,
'message' => $e->getMessage()
];
}
}
/**
* Delete Single
* @since 1.0.0
* @return boolean
*/
public function deleteSingle($key){
$result = array();
if (!$this->bucket) {
return array(
'success' => false,
'code' => 200,
'message' => esc_html__('Client not configured', 'media-cloud-sync')
);
}
if (isset($key) && !empty($key)) {
try {
$object = $this->bucket->object($key);
$object->delete();
if (!$object->exists()) {
$result = array(
'success' => true,
'code' => 200,
'message' => esc_html__('Deleted Successfully', 'media-cloud-sync'),
);
} else {
$result = array(
'success' => false,
'code' => 200,
'message' => esc_html__('File not deleted', 'media-cloud-sync'),
);
}
} catch (Exception $e) {
$result = array(
'success' => false,
'code' => 200,
'message' => $e->getMessage(),
);
}
} else {
$result = array(
'success' => false,
'code' => 200,
'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
);
}
return $result;
}
/**
* get private URL
* @since 1.0.0
* @return boolean
*/
public function get_private_url($key) {
$result = array();
if (!$this->bucket) {
return array(
'success' => false,
'code' => 200,
'message' => esc_html__('Client not configured', 'media-cloud-sync')
);
}
if (isset($key) && !empty($key)) {
try {
$object = $this->bucket->object($key);
$expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
$privateUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
if ($privateUrl) {
$result = array(
'success' => true,
'code' => 200,
'file_url' => $privateUrl,
'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync'),
);
} else {
$result = array(
'success' => false,
'code' => 200,
'message' => esc_html__('Error getting private URL', 'media-cloud-sync'),
);
}
} catch (Exception $e) {
$result = array(
'success' => false,
'code' => 200,
'message' => $e->getMessage(),
);
}
} else {
$result = array(
'success' => false,
'code' => 200,
'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
);
}
return $result;
}
/**
* Generate file URL
*/
public function generate_file_url($key){
$domain = $this->get_domain();
return apply_filters('wpmcs_generate_google_file_url',
$domain . '/' . $this->bucket_name . '/' . $key,
$domain, $key,
$this->bucket_name
);
}
/**
* Is provider URL
* @since 1.3.6
*/
public function is_provider_url($url) {
$domain = $this->get_domain();
return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
}
/**
* Get domain URL
*/
public function get_domain() {
$url_base = 'https://storage.googleapis.com';
return $url_base;
}
}