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 +1087 -235 1.1.01.4.1 View file →
@@ -3,9 +3,11 @@
3 3
4 4 defined('ABSPATH') || exit;
5 5
6 6 // Libraries
7 -use Dudlewebs\WPMCS\Google\Cloud\Storage\StorageClient;
7 +use Dudlewebs\WPMCS\GCP\Google\Cloud\Storage\StorageClient;
8 +use Dudlewebs\WPMCS\GCP\Google\Cloud\Core\Exception\ServiceException;
9 +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialsLoader;
8 10
9 11 use Exception;
10 12
11 13 class GCloud {
@@ -18,8 +20,9 @@
18 20 protected $settings;
19 21 protected $credentials;
20 22 protected $bucket_name;
21 23 protected $bucket; // Object
24 + protected $cdnConfig;
22 25
23 26 public $service = 'gcloud';
24 27 public $gcloudClient = false;
25 28
@@ -26,23 +29,26 @@
26 29 /**
27 30 * Admin constructor.
28 31 * @since 1.0.0
29 32 */
30 - public function __construct() {
33 + public function __construct($credentials = null) {
31 34 $this->assets_url = WPMCS_ASSETS_URL;
32 35 $this->version = WPMCS_VERSION;
33 36 $this->token = WPMCS_TOKEN;
34 37
35 38 // Initialize setup
36 - $this->init();
39 + $this->init($credentials);
37 40 }
38 41
39 42 /**
40 43 * Initialise Client
44 + *
45 + * @param array|null $credentials Optional explicit credentials; falls back to
46 + * Utils::get_credentials() when omitted.
41 47 */
42 - public function init() {
48 + public function init($credentials = null) {
43 49 $this->settings = Utils::get_settings();
44 - $this->credentials = Utils::get_credentials();
50 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
45 51 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
46 52 ? $this->credentials['config']
47 53 : [];
48 54 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -50,31 +56,40 @@
50 56 : [];
51 57 $this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
52 58 ? $this->bucketConfig['bucket_name']
53 59 : '';
60 + $this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
61 + ? $this->credentials['cdn']
62 + : [];
54 63
55 64 if (
56 65 isset($this->config['config_json']) && !empty($this->config['config_json']) &&
57 - isset($this->config['config_json']['path']) && !empty($this->config['config_json']['path']) &&
58 66 isset($this->bucket_name) && !empty($this->bucket_name)
59 67 ) {
60 - if(file_exists($this->config['config_json']['path'])){
68 + if(Utils::is_json($this->config['config_json'])){
61 69 // Set google client
62 - $this->gcloudClient = new StorageClient([
63 - 'keyFilePath' => $this->config['config_json']['path'],
64 - ]);
65 - // Set bucket object
66 - $this->bucket = $this->gcloudClient->bucket($this->bucket_name);
70 + $keyArray = json_decode($this->config['config_json'], true);
71 +
72 + if (is_array($keyArray)) {
73 + $this->gcloudClient = new StorageClient([
74 + 'keyFile' => $keyArray,
75 + ]);
76 + $this->bucket = $this->gcloudClient->bucket($this->bucket_name);
77 + } else {
78 + // Handle JSON decode failure
79 + throw new \Exception('Invalid JSON provided for GCloud credentials.');
80 + }
67 81 } else {
68 82 add_action('admin_notices', function (){
69 - echo wp_kses_post(sprintf( "<div class='error'><p><strong>%s: </strong><br>Google Cloud Storage configuration file missing from the directory.
70 - It may break the media url's as well as media uploads.<br>
71 - <a href='%s'>Re-configure</a> plugin to fix the issue.
72 - </p></div>",
73 - esc_html__('Media Cloud Sync', 'media-cloud-sync'),
74 - admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
75 - ));
83 + echo wp_kses_post(sprintf( "<div class='error'><p><strong>%s: </strong><br>Google Cloud Storage configuration is invalid.
84 + It may break the media url's as well as media uploads.<br>
85 + <a href='%s'>Re-configure</a> plugin to fix the issue.
86 + </p></div>",
87 + esc_html__('Media Cloud Sync', 'media-cloud-sync'),
88 + admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
89 + ));
76 90 });
91 +
77 92 }
78 93 }
79 94 }
80 95
@@ -82,40 +97,93 @@
82 97 * Verify Credentials
83 98 * @since 1.0.0
84 99 * @return boolean
85 100 */
86 - public function verifyCredentials( $config_file ){
87 - if (
88 - isset($config_file) && !empty($config_file) &&
89 - isset($config_file['path']) && !empty($config_file['path']) &&
90 - file_exists($config_file['path'])
91 - ) {
101 + public function verifyCredentials( $config = [] ){
102 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
103 + if (!Service::has_missing_fields([$config_json])) {
104 + if(!Utils::is_json($config_json)){
105 + return [
106 + 'success' => false,
107 + 'code' => 200,
108 + 'message' => esc_html__('Invalid JSON configuration, please try again', 'media-cloud-sync'),
109 + ];
110 + }
111 +
92 112 try {
93 - $googleClient = new StorageClient([
94 - 'keyFilePath' => $config_file['path'],
95 - ]);
113 + $config_array = json_decode($config_json, true);
114 + if (is_array($config_array)) {
115 + $googleClient = new StorageClient([
116 + 'keyFile' => $config_array
117 + ]);
118 + } else {
119 + return [
120 + 'success' => false,
121 + 'code' => 200,
122 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
123 + ];
124 + }
96 125
126 + $result = [
127 + 'success' => false,
128 + 'code' => 200,
129 + 'message' => esc_html__('Please check the authorization details', 'media-cloud-sync'),
130 + ];
97 131
98 - //Listing all google Buckets
99 - $buckets = $googleClient->buckets();
132 + try {
133 + $bucket = $googleClient->bucket($this->token . '_dummy-bucket-for-auth-check');
134 + $exists = $bucket->exists(); // Triggers the API call
100 135
101 - $newBucketFormat = [];
102 - if(isset($buckets) && !empty($buckets)){
103 - foreach($buckets as $bucket) {
104 - $name = $bucket->name();
105 - if(!empty($name)) {
106 - // Fetch the bucket's metadata
107 - $bucketInfo = $bucket->info();
108 - $newBucketFormat[] = ['Name' => $name, 'CreationDate' => $bucketInfo['timeCreated']];
136 + // If we reach here, the credentials are valid
137 + $result = [
138 + 'success' => true,
139 + 'code' => 200,
140 + 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
141 + ];
142 + } catch (ServiceException $e) {
143 + $statusCode = $e->getCode();
144 +
145 + $validErrors = [200, 403, 404];
146 +
147 + if (in_array($statusCode, $validErrors)) {
148 + // If we reach here, the credentials are valid
149 + $result = [
150 + 'success' => true,
151 + 'code' => 200,
152 + 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'),
153 + ];
154 + }
155 + }
156 +
157 + if($result['success'] == false) {
158 + return $result;
159 + }
160 +
161 +
162 + try {
163 + $buckets = $googleClient->buckets();
164 + $newBucketFormat = [];
165 + if(isset($buckets) && !empty($buckets)){
166 + foreach($buckets as $bucket) {
167 + $name = $bucket->name();
168 + if(!empty($name)) {
169 + // Fetch the bucket's metadata
170 + $bucketInfo = $bucket->info();
171 + $newBucketFormat[] = ['Name' => $name, 'CreationDate' => $bucketInfo['timeCreated']];
172 + }
109 173 }
110 174 }
175 + $result['buckets_data']['buckets'] = $newBucketFormat;
176 + $result['buckets_data']['message'] = esc_html__('Buckets listed successfully', 'media-cloud-sync');
177 + $result['buckets_data']['status'] = true;
178 + } catch (Exception $e) {
179 + $result ['buckets_data']['buckets'] = [];
180 + $result ['buckets_data']['message'] = esc_html__('Unable to list buckets, Please check the bucket listing permission', 'media-cloud-sync');
181 + $result ['buckets_data']['status'] = false;
111 182 }
112 -
113 - return array( 'message' => esc_html__('Credentials are valid', 'media-cloud-sync'), 'buckets' => $newBucketFormat, 'code' => 200, 'success' => true);
183 + return $result;
114 184 } catch (Exception $ex) {
115 185 return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
116 - } catch (Exception $ex) {
117 - return array('message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false);
118 186 }
119 187 }
120 188 return array('message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false);
121 189 }
@@ -121,80 +189,61 @@
121 189 }
122 190
123 191
124 192 /**
125 - * Verify Bucket
193 + * Verify Bucket Exists
126 194 * @since 1.0.0
127 195 * @return boolean
128 196 */
129 - public function verifyBucket( $config_file, $bucket_name ){
130 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($bucket_name) ) ) {
197 + public function verifyBucketExist( $config = [], $bucketConfig = [] ){
198 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
199 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
200 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
131 201 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
132 202 }
133 203
204 + if ( !Utils::is_json( $config_json ) ) {
205 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
206 + }
207 +
134 208 try {
135 - $googleClient = new StorageClient([
136 - 'keyFilePath' => $config_file['path'],
137 - ]);
138 -
139 - $bucket = $googleClient->bucket($bucket_name);
140 - if ($bucket->exists()) {
141 - $bucket_found = true;
209 + $config_array = json_decode($config_json, true);
210 + if (is_array($config_array)) {
211 + $googleClient = new StorageClient([
212 + 'keyFile' => $config_array
213 + ]);
142 214 } else {
143 - return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
215 + return [
216 + 'success' => false,
217 + 'code' => 200,
218 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
219 + ];
144 220 }
145 - if ($bucket_found) {
146 - $upload_dir = wp_upload_dir();
147 - $file_dir = $upload_dir['basedir'] . '/' . Schema::getConstant('UPLOADS') . '/';
148 221
149 - if (!is_dir($file_dir)) {
150 - do_action( $this->token.'_create_plugin_dir' );
151 - }
222 + try {
223 + $bucket = $googleClient->bucket($bucket_name);
152 224
153 - $fileName = $this->token."_verify.txt";
154 - $localFileName = $file_dir.$this->token."-local-verify.txt";
155 -
156 - $verify_file = fopen($file_dir.$fileName, "w");
157 - $txt = "We are verifying input/output operations in Google Cloud\n";
158 - fwrite($verify_file, $txt);
159 - fclose($verify_file);
160 -
161 - $upload = $bucket->upload(
162 - fopen($file_dir . $fileName, 'r'),
163 - [
164 - 'name' => $fileName,
165 - 'predefinedAcl' => 'publicRead',
166 - ]
167 - );
168 -
169 - @unlink($file_dir . $fileName);
170 -
171 - $object = $bucket->object($fileName);
172 - if ($object->exists()) {
173 - $object->downloadToFile($localFileName);
174 - if (file_exists($localFileName)) {
175 - @unlink($localFileName);
176 - $object->delete();
177 -
178 - if (!$object->exists()) {
179 - return array('message' => esc_html__('Configuration for Google Cloud Storage verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true);
180 - } else {
181 - return array('message' => esc_html__('Bucket has permission issues on deleting the object from bucket, Please check permission as well as policies', 'media-cloud-sync'), 'code' => 200, 'success' => false);
182 - }
183 - } else {
184 - return array('message' => esc_html__('Bucket has permission issues on getting the object from bucket, Please check permission as well as policies', 'media-cloud-sync'), 'code' => 200, 'success' => false);
185 - }
225 + if ($bucket->exists()) {
226 + return [
227 + 'message' => esc_html__('Bucket exists', 'media-cloud-sync'),
228 + 'code' => 200,
229 + 'success' => true,
230 + ];
186 231 } else {
187 - return array('message' => esc_html__('Bucket has permission issues on putting object in to bucket, Please check permission as well as policies', 'media-cloud-sync'), 'code' => 200, 'success' => false);
232 + return [
233 + 'message' => esc_html__('Bucket does not exist', 'media-cloud-sync'),
234 + 'code' => 200,
235 + 'success' => false,
236 + ];
188 237 }
189 - } else {
190 - return ['message' => esc_html__('Bucket Name is incorrect', 'media-cloud-sync'), 'code' => 200, 'success' => false];
191 - }
238 + } catch (ServiceException $e) {
239 + return [
240 + 'message' => esc_html__('Bucket does not exist or credentials are invalid: ', 'media-cloud-sync') . $e->getMessage(),
241 + 'code' => 200,
242 + 'success' => false,
243 + ];
244 + }
192 245 } catch (Exception $ex) {
193 - return ['message' => $e->getMessage(), 'code' => 200, 'success' => false];
194 - } catch (Exception $ex) {
195 - return ['message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
196 - } catch (Exception $ex) {
197 246 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
198 247 }
199 248 }
200 249
@@ -203,25 +252,83 @@
203 252 * Create Bucket
204 253 * @since 1.0.0
205 254 * @return boolean
206 255 */
207 - public function createBucket( $config_file, $region, $bucket_name ){
208 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($region) && !empty($bucket_name) ) ) {
256 + public function createBucket( $config = [], $bucketConfig = [] ){
257 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
258 + $region = isset($bucketConfig['region']) ? $bucketConfig['region'] : '';
259 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
260 + if ( Service::has_missing_fields([$config_json, $region, $bucket_name]) ) {
209 261 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
210 262 }
211 263
264 + if ( !Utils::is_json( $config_json ) ) {
265 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
266 + }
267 +
212 268 try {
213 - $googleClient = new StorageClient([
214 - 'keyFilePath' => $config_file['path'],
215 - ]);
269 + $config_array = json_decode($config_json, true);
270 + if (is_array($config_array)) {
271 + $googleClient = new StorageClient([
272 + 'keyFile' => $config_array
273 + ]);
274 + } else {
275 + return [
276 + 'success' => false,
277 + 'code' => 200,
278 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
279 + ];
280 + }
216 281
217 282 // Create Bucket
218 - $googleClient->createBucket($bucket_name, [
283 + $bucket = $googleClient->createBucket($bucket_name, [
219 284 'location' => $region,
285 + 'iamConfiguration' => [
286 + 'uniformBucketLevelAccess' => [
287 + 'enabled' => true
288 + ]
289 + ]
220 290 ]);
221 291
292 + // Fetch the bucket's IAM
293 + try {
294 + $iam = $bucket->iam();
295 +
296 + $policy = $iam->policy();
297 +
298 + // Add allUsers as a Storage Object Viewer
299 + $policy['bindings'][] = [
300 + 'role' => 'roles/storage.objectViewer',
301 + 'members' => ['allUsers'],
302 + ];
303 +
304 + // Set the updated policy
305 + $iam->setPolicy($policy);
306 +
307 + } catch (ServiceException $e) {
308 + return [
309 + 'message' => esc_html__('Bucket created successfully. But failed to set IAM policy.', 'media-cloud-sync'),
310 + 'data' => [
311 + 'Name' => $bucket_name,
312 + 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
313 + ],
314 + 'code' => 200,
315 + 'success' => true,
316 + ];
317 + } catch (Exception $e) {
318 + return [
319 + 'message' => esc_html__('Bucket created successfully. But failed to set IAM policy.', 'media-cloud-sync'),
320 + 'data' => [
321 + 'Name' => $bucket_name,
322 + 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
323 + ],
324 + 'code' => 200,
325 + 'success' => true,
326 + ];
327 + }
328 +
222 329 return [
223 - 'message' => esc_html__('Bucket created successfully. Choose bucket from list to select the bucket.', 'media-cloud-sync'),
330 + 'message' => esc_html__('Bucket created successfully.', 'media-cloud-sync'),
224 331 'data' => [
225 332 'Name' => $bucket_name,
226 333 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
227 334 ],
@@ -229,11 +336,142 @@
229 336 'success' => true,
230 337 ];
231 338
232 339 } catch (Exception $ex) {
233 - return ['message' => $e->getMessage(), 'code' => 200, 'success' => false];
340 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
341 + }
342 + }
343 +
344 + /**
345 + * Check Bucket Write Permission
346 + * @since 1.0.0
347 + */
348 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
349 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
350 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
351 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
352 + return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
353 + }
354 +
355 + if ( !Utils::is_json( $config_json ) ) {
356 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
357 + }
358 +
359 + try {
360 + $config_array = json_decode($config_json, true);
361 + if (is_array($config_array)) {
362 + $googleClient = new StorageClient([
363 + 'keyFile' => $config_array
364 + ]);
365 + } else {
366 + return [
367 + 'success' => false,
368 + 'code' => 200,
369 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
370 + ];
371 + }
372 + $bucket = $googleClient->bucket($bucket_name);
373 + if ($bucket->exists()) {
374 + $bucket_found = true;
375 + } else {
376 + return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
377 + }
378 + if ($bucket_found) {
379 + $object_key = Utils::get_permission_check_object_key();
380 +
381 + // Prepare a temporary file with content to check write permission
382 + $stream = fopen('php://temp', 'r+');
383 + fwrite($stream, 'This is a test object to check write permission.');
384 + rewind($stream);
385 +
386 + // Upload the object to the bucket
387 + $object = $bucket->upload(
388 + $stream,
389 + [
390 + 'name' => $object_key,
391 + ]
392 + );
393 +
394 + if ($object->exists()) {
395 + return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
396 + } else {
397 + return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
398 + }
399 + }
234 400 } catch (Exception $ex) {
235 - return ['message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
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 + }
406 + }
407 + }
408 +
409 + /**
410 + * Check Bucket Delete Permission
411 + * @since 1.0.0
412 + */
413 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
414 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
415 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
416 +
417 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
418 + return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
419 + }
420 + if( !Utils::is_json( $config_json ) ) {
421 + return ['message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'), 'code' => 200, 'success' => false];
422 + }
423 +
424 + try {
425 + $config_array = json_decode($config_json, true);
426 + if (is_array($config_array)) {
427 + $googleClient = new StorageClient([
428 + 'keyFile' => $config_array
429 + ]);
430 + } else {
431 + return [
432 + 'success' => false,
433 + 'code' => 200,
434 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
435 + ];
436 + }
437 +
438 + $bucket = $googleClient->bucket($bucket_name);
439 + try {
440 + $object_key = Utils::get_permission_check_object_key();
441 +
442 + // Try fetching a dummy object to test access
443 + $object = $bucket->object($object_key);
444 + if ($object->exists()) {
445 + $object->delete();
446 + if (!$object->exists()) {
447 + return [
448 + 'message' => esc_html__('Bucket exists', 'media-cloud-sync'),
449 + 'code' => 200,
450 + 'success' => true,
451 + ];
452 + } else {
453 + return [
454 + 'message' => esc_html__('You do not have permission to delete object', 'media-cloud-sync'),
455 + 'code' => 200,
456 + 'success' => false,
457 + ];
458 + }
459 + } else {
460 + return [
461 + 'message' => esc_html__('Object does not exist', 'media-cloud-sync'),
462 + 'code' => 200,
463 + 'success' => false,
464 + ];
465 + }
466 + } catch (Exception $ex) {
467 + return [
468 + 'message' => esc_html__('Object does not exist or credentials are invalid: ', 'media-cloud-sync') . $ex->getMessage(),
469 + 'code' => 200,
470 + 'success' => false,
471 + ];
472 + }
473 +
236 474 } catch (Exception $ex) {
237 475 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
238 476 }
239 477 }
@@ -238,8 +476,108 @@
238 476 }
239 477 }
240 478
241 479 /**
480 + * Check Bucket Read Permission
481 + * @since 1.2.4
482 + */
483 + public function verifyObjectReadPermission() {
484 + $result = [
485 + 'status' => false,
486 + 'message' => '',
487 + 'lastChecked' => time(),
488 + ];
489 + if (Service::has_missing_fields([$this->gcloudClient, $this->bucket_name])) {
490 + $result['message'] = esc_html__('Please check the authorization details', 'media-cloud-sync');
491 + return [
492 + 'message' => $result['message'],
493 + 'code' => 200,
494 + 'success' => false,
495 + 'lastChecked' => $result['lastChecked'],
496 + ];
497 + }
498 +
499 + try {
500 + $object_key = Utils::get_permission_check_object_key();
501 +
502 + // Check if the object was created successfully
503 + if (!$this->exists($object_key)) {
504 + // Create a dummy object to check write permission
505 + $stream = fopen('php://temp', 'r+');
506 + fwrite($stream, 'This is a test object to check read permission.');
507 + rewind($stream);
508 + $this->bucket->upload(
509 + $stream,
510 + [
511 + 'name' => $object_key,
512 + 'metadata' => ['cacheControl' => 'no-cache, no-store, must-revalidate'],
513 + ]
514 + );
515 + // Re-check if the object was created successfully
516 + if (!$this->exists($object_key)) {
517 + $result['status'] = false;
518 + $result['message'] = esc_html__('Failed to create an object for read permission check, please check service configuration', 'media-cloud-sync');
519 + return [
520 + 'message' => $result['message'],
521 + 'code' => 200,
522 + 'success' => false,
523 + 'lastChecked' => $result['lastChecked'],
524 + ];
525 + }
526 + }
527 +
528 + $url = $this->generate_file_url($object_key);
529 + $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
530 +
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) {
540 + $result['status'] = true;
541 + $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
542 + } else if ($status_code === 403) {
543 + $result['status'] = false;
544 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
545 + $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
546 + } else {
547 + $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
548 + }
549 + } else if ($status_code === 404) {
550 + $result['status'] = false;
551 + $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
552 + } else if ($status_code === 500) {
553 + $result['status'] = false;
554 + $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
555 + } else {
556 + $result['status'] = false;
557 + $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
558 + }
559 + $this->deleteSingle($object_key);
560 + return [
561 + 'message' => $result['message'],
562 + 'code' => 200,
563 + 'success' => $result['status'],
564 + 'lastChecked' => $result['lastChecked'],
565 + ];
566 + } catch (ServiceException $ex) {
567 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
568 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
569 + } catch (Exception $ex) {
570 + $result['message'] = $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync');
571 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false, 'lastChecked' => time()];
572 + } finally {
573 + if (isset($stream) && is_resource($stream)) {
574 + fclose($stream);
575 + }
576 + }
577 + }
578 +
579 + /**
242 580 * isConfigured Function To Identify the congfigurations are correct
243 581 * @since 1.0.0
244 582 */
245 583 public function isConfigured(){
@@ -244,20 +582,23 @@
244 582 */
245 583 public function isConfigured(){
246 584 if ($this->gcloudClient) {
247 585 try {
248 - $buckets = $this->gcloudClient->buckets();
249 - if(!empty($buckets)){
250 - foreach($buckets as $bucket) {
251 - $name = $bucket->name();
252 - if (!empty($name) && $name == $this->bucket_name) {
253 - return true;
254 - }
255 - }
256 - }
257 - return false;
258 - } catch (Exception $ex) {
259 - return false;
586 + $bucket = $this->gcloudClient->bucket($this->token . '_dummy-bucket-for-auth-check');
587 + $exists = $bucket->exists(); // Triggers the API call
588 + return true;
589 + } catch (ServiceException $e) {
590 + $statusCode = $e->getCode();
591 +
592 + $validErrors = [200, 403, 404];
593 +
594 + if (in_array($statusCode, $validErrors)) {
595 + // If we reach here, the credentials are valid
596 + return true;
597 + } else {
598 + // If we reach here, the credentials are not valid
599 + return false;
600 + }
260 601 }
261 602 }
262 603 return false;
263 604 }
@@ -268,13 +609,22 @@
268 609 *
269 610 */
270 611 public function toPrivate($key) {
271 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
272 614
273 - $object = $this->bucket->object($key);
274 - if ($object->exists()) {
275 - $object->update(['acl' => []], ['predefinedAcl' => 'private']);
276 - return true;
615 + try {
616 + $object = $this->bucket->object($key);
617 + if ($object->exists()) {
618 + $object->update(['acl' => []], ['predefinedAcl' => 'private']);
619 + return true;
620 + }
621 + } catch (ServiceException $e) {
622 + // Handle exception if needed
623 + return false;
624 + } catch (Exception $e) {
625 + // Handle other exceptions if needed
626 + return false;
277 627 }
278 628 return false;
279 629 }
280 630
@@ -281,32 +631,342 @@
281 631
282 632 /**
283 633 * Make Object Public
284 634 * @since 1.0.0
285 - *
635 + *
286 636 */
287 637 public function toPublic($key) {
288 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
289 640
290 - $object = $this->bucket->object($key);
291 - if ($object->exists()) {
292 - $object->update(['acl' => []], ['predefinedAcl' => 'publicRead']);
293 - return true;
641 + try {
642 + $object = $this->bucket->object($key);
643 + if ($object->exists()) {
644 + $object->update(['acl' => []], ['predefinedAcl' => 'publicRead']);
645 + return true;
646 + }
647 + return false;
648 + } catch (ServiceException $e) {
649 + // Handle exception if needed
650 + return false;
651 + } catch (Exception $e) {
652 + // Handle other exceptions if needed
653 + return false;
294 654 }
295 - return false;
296 655 }
297 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]);
298 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 +
299 686 /**
300 - * 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
301 950 * @since 1.1.8
302 951 */
303 - public function exists($key) {
952 + public function exists($key, $bucket = null) {
304 953 if(!$key) return false;
305 954
306 - $object = $this->bucket->object($key);
307 - if ($object->exists()) {
308 - return true;
955 + try {
956 + $bucket = $bucket ?? $this->bucket;
957 + $object = $bucket->object($key);
958 + if ($object->exists()) {
959 + return true;
960 + } else {
961 + return false;
962 + }
963 + } catch (ServiceException $e) {
964 + // Handle exception if needed
965 + return false;
966 + } catch (Exception $e) {
967 + // Handle other exceptions if needed
968 + return false;
309 969 }
310 970
311 971 return false;
312 972 }
@@ -312,118 +972,154 @@
312 972 }
313 973
314 974
315 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 + /**
316 1031 * Upload Single
317 1032 * @since 1.0.0
318 1033 * @return boolean
319 1034 */
320 - public function uploadSingle($media_absolute_path, $media_path, $prefix=''){
321 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
322 1036 if (
323 - isset($media_absolute_path) && !empty($media_absolute_path) &&
324 - isset($media_path) && !empty($media_path)
1037 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1038 + isset($relative_source_path) && !empty($relative_source_path)
325 1039 ) {
326 - $file_name = wp_basename( $media_path );
1040 + $file_name = wp_basename( $relative_source_path );
327 1041 if ($file_name) {
328 - $upload_path = Utils::generate_object_key($file_name, $prefix);
1042 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
1043 + if ($upload_path === false) {
1044 + return [
1045 + 'success' => false,
1046 + 'code' => 200,
1047 + 'message' => esc_html__('This file is marked private, but the private-media add-on is not currently active — reupload skipped to avoid exposing it.', 'media-cloud-sync')
1048 + ];
1049 + }
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
1051 + }
1052 + return [
1053 + 'success' => false,
1054 + 'code' => 200,
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
1056 + ];
1057 + }
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
1062 + ];
1063 + }
329 1064
330 - // Decide Multipart upload or normal put object
331 - if (filesize($media_absolute_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
332 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
333 - try {
1065 + /**
1066 + * Upload a local file to an exact destination key (no Utils::generate_object_key() derivation).
1067 + * @since 1.4.0
1068 + */
1069 + public function uploadObjectAtKey($absolute_source_path, $key) {
1070 + return $this->execute_upload($absolute_source_path, $key);
1071 + }
334 1072
335 - $upload = $this->bucket->upload(
336 - fopen($media_absolute_path, 'r'),
337 - [
338 - 'name' => $upload_path,
339 - 'predefinedAcl' => 'publicRead',
340 - ]
341 - );
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 + }
342 1084
343 - $object = $this->bucket->object($upload_path);
1085 + try {
1086 + $handle = fopen($absolute_source_path, 'rb');
1087 + $upload = $this->bucket->upload($handle, $options);
344 1088
345 - if ($object->exists()) {
346 - $result = array(
347 - 'success' => true,
348 - 'code' => 200,
349 - 'file_url' => $this->generate_file_url($upload_path),
350 - 'key' => $upload_path,
351 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
352 - );
353 - } else {
354 - $result = array(
355 - 'success' => false,
356 - 'code' => 200,
357 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
358 - );
359 - }
360 - } catch (Exception $e) {
361 - $result = array(
362 - 'success' => false,
363 - 'code' => 200,
364 - 'message' => $e->getMessage(),
365 - );
366 - }
367 - } else {
368 - try {
369 - $upload = $this->bucket->upload(
370 - fopen($media_absolute_path, 'r'),
371 - [
372 - 'name' => $upload_path,
373 - 'predefinedAcl' => 'publicRead',
374 - 'chunkSize' => 262144 * 2,
375 - ]
376 - );
377 -
378 - $object = $this->bucket->object($upload_path);
379 -
380 - if ($object->exists()) {
381 - $result = array(
382 - 'success' => true,
383 - 'code' => 200,
384 - 'file_url' => $this->generate_file_url($upload_path),
385 - 'key' => $upload_path,
386 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
387 - );
388 - } else {
389 - $result = array(
390 - 'success' => false,
391 - 'code' => 200,
392 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
393 - );
394 - }
395 - } catch (Exception $e) {
396 - $result = array(
397 - 'success' => false,
398 - 'code' => 200,
399 - 'message' => $e->getMessage(),
400 - );
401 - }
402 - }
403 - } else {
404 - $result = array(
405 - 'success' => false,
406 - 'code' => 200,
407 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
408 - );
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 + ];
409 1097 }
410 - } else {
411 - $result = array(
412 - 'success' => false,
413 - 'code' => 200,
414 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
415 - );
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 + }
416 1113 }
417 - return $result;
418 1114 }
419 1115
420 -
421 1116 /**
422 1117 * Save object to server
423 1118 * @since 1.0.0
424 1119 */
425 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
426 1122 try {
427 1123 $object = $this->bucket->object($key);
428 1124 if ($object->exists()) {
429 1125 $object->downloadToFile($save_path);
@@ -436,9 +1132,143 @@
436 1132 }
437 1133 return false;
438 1134 }
439 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 + }
440 1153
1154 + /**
1155 + * Deletes the live generation, then best-effort purges every prior generation too — a
1156 + * bucket with Object Versioning enabled otherwise keeps old generations (and the storage
1157 + * they use) around at the old key. The live delete happens unconditionally first, in its
1158 + * own try/catch, so the object still ends up gone even if the generation-listing call
1159 + * below fails for any reason.
1160 + * @since 1.3.14
1161 + */
1162 + public function purge_all_versions($key) {
1163 + if (!$this->bucket) {
1164 + return ['success' => false, 'code' => 200, 'message' => esc_html__('Client not configured', 'media-cloud-sync')];
1165 + }
1166 +
1167 + try {
1168 + $this->bucket->object($key)->delete();
1169 + } catch (ServiceException $e) {
1170 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1171 + } catch (\Exception $e) {
1172 + return ['success' => false, 'code' => 200, 'message' => $e->getMessage()];
1173 + }
1174 +
1175 + // Best-effort only from here — the live copy above is already gone regardless of
1176 + // whether this bucket has Object Versioning enabled or this call succeeds.
1177 + try {
1178 + foreach ($this->bucket->objects(['prefix' => $key, 'versions' => true]) as $object) {
1179 + if ($object->name() === $key) {
1180 + $object->delete();
1181 + }
1182 + }
1183 + } catch (ServiceException $e) {
1184 + // Generation history cleanup failed — not fatal, live object is gone.
1185 + } catch (\Exception $e) {
1186 + // Generation history cleanup failed — not fatal, live object is gone.
1187 + }
1188 +
1189 + return ['success' => true, 'code' => 200, 'message' => esc_html__('Purged Successfully', 'media-cloud-sync')];
1190 + }
1191 +
1192 +
1193 + /**
1194 + * Copy an object to a new path in Google Cloud Storage
1195 + *
1196 + * @param string $key Original object key (path in bucket)
1197 + * @param string $new_path Destination object key
1198 + * @return bool True if object was copied successfully, false otherwise
1199 + * @since 1.3.4
1200 + */
1201 + // Trusts copy()'s own success/failure rather than pre/post-verifying with extra
1202 + // exists() calls — each one is a full network round-trip, and with move/copy processing
1203 + // keys sequentially, extra round-trips per file add up fast on a folder with many files.
1204 + // copy() itself throws (caught below) if the source is missing or the copy otherwise
1205 + // fails, so nothing is lost by not checking first.
1206 + public function copy_to_new_path($key, $new_path) {
1207 + if (!$this->bucket) {
1208 + return [
1209 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1210 + 'code' => 200,
1211 + 'success' => false
1212 + ];
1213 + }
1214 + try {
1215 + $sourceObject = $this->bucket->object($key);
1216 + $sourceObject->copy($this->bucket, ['name' => $new_path]);
1217 + return [
1218 + 'success' => true,
1219 + 'code' => 200,
1220 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1221 + ];
1222 + } catch (ServiceException $e) {
1223 + return [
1224 + 'success' => false,
1225 + 'code' => 200,
1226 + 'message' => $e->getMessage()
1227 + ];
1228 + } catch (\Exception $e) {
1229 + return [
1230 + 'success' => false,
1231 + 'code' => 200,
1232 + 'message' => $e->getMessage()
1233 + ];
1234 + }
1235 + }
1236 +
1237 + // Like copy_to_new_path() but into an explicit (possibly different) bucket — needs write
1238 + // access there too, so callers should fall back to download+upload on failure.
1239 + public function copy_to_bucket($key, $new_key, $dest_bucket) {
1240 + if (!$this->bucket || !$this->gcloudClient) {
1241 + return [
1242 + 'message' => esc_html__('Client not configured', 'media-cloud-sync'),
1243 + 'code' => 200,
1244 + 'success' => false
1245 + ];
1246 + }
1247 + try {
1248 + $sourceObject = $this->bucket->object($key);
1249 + $sourceObject->copy($this->gcloudClient->bucket($dest_bucket), ['name' => $new_key]);
1250 + return [
1251 + 'success' => true,
1252 + 'code' => 200,
1253 + 'message' => esc_html__('File copied successfully', 'media-cloud-sync')
1254 + ];
1255 + } catch (ServiceException $e) {
1256 + return [
1257 + 'success' => false,
1258 + 'code' => 200,
1259 + 'message' => $e->getMessage()
1260 + ];
1261 + } catch (\Exception $e) {
1262 + return [
1263 + 'success' => false,
1264 + 'code' => 200,
1265 + 'message' => $e->getMessage()
1266 + ];
1267 + }
1268 + }
1269 +
1270 +
441 1271 /**
442 1272 * Delete Single
443 1273 * @since 1.0.0
444 1274 * @return boolean
@@ -444,8 +1274,15 @@
444 1274 * @return boolean
445 1275 */
446 1276 public function deleteSingle($key){
447 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 + }
448 1285 if (isset($key) && !empty($key)) {
449 1286 try {
450 1287 $object = $this->bucket->object($key);
451 1288 $object->delete();
@@ -481,34 +1318,41 @@
481 1318 }
482 1319
483 1320
484 1321 /**
485 - * get presigned URL
1322 + * get private URL
486 1323 * @since 1.0.0
487 1324 * @return boolean
488 1325 */
489 - public function get_presigned_url($key) {
1326 + public function get_private_url($key) {
490 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 + }
491 1335 if (isset($key) && !empty($key)) {
492 1336 try {
493 1337 $object = $this->bucket->object($key);
494 1338
495 - $expires = isset($this->settings['presigned_expire']) ? $this->settings['presigned_expire'] : 20;
1339 + $expires = isset($this->settings['private_url_expire']) ? $this->settings['private_url_expire'] : 20;
496 1340
497 - $presignedUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
1341 + $privateUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
498 1342
499 - if ($presignedUrl) {
1343 + if ($privateUrl) {
500 1344 $result = array(
501 1345 'success' => true,
502 1346 'code' => 200,
503 - 'file_url' => $presignedUrl,
504 - 'message' => esc_html__('Got Presigned URL Successfully', 'media-cloud-sync'),
1347 + 'file_url' => $privateUrl,
1348 + 'message' => esc_html__('Got Private URL Successfully', 'media-cloud-sync'),
505 1349 );
506 1350 } else {
507 1351 $result = array(
508 1352 'success' => false,
509 1353 'code' => 200,
510 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync'),
1354 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync'),
511 1355 );
512 1356 }
513 1357 } catch (Exception $e) {
514 1358 $result = array(
@@ -530,9 +1374,9 @@
530 1374
531 1375 /**
532 1376 * Generate file URL
533 1377 */
534 - private function generate_file_url($key){
1378 + public function generate_file_url($key){
535 1379 $domain = $this->get_domain();
536 1380
537 1381 return apply_filters('wpmcs_generate_google_file_url',
538 1382 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -540,8 +1384,16 @@
540 1384 $this->bucket_name
541 1385 );
542 1386 }
543 1387
1388 + /**
1389 + * Is provider URL
1390 + * @since 1.3.6
1391 + */
1392 + public function is_provider_url($url) {
1393 + $domain = $this->get_domain();
1394 + return (strpos($url, $domain . '/' . $this->bucket_name . '/') !== false);
1395 + }
544 1396
545 1397 /**
546 1398 * Get domain URL
547 1399 */