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 +940 -209 1.2.01.4.1 View file →
@@ -3,10 +3,11 @@
3 3
4 4 defined('ABSPATH') || exit;
5 5
6 6 // Libraries
7 -use Dudlewebs\WPMCS\Google\Cloud\Storage\StorageClient;
8 -use Dudlewebs\WPMCS\Google\Cloud\Core\Exception\ServiceException;
7 +use Dudlewebs\WPMCS\GCP\Google\Cloud\Storage\StorageClient;
8 +use Dudlewebs\WPMCS\GCP\Google\Cloud\Core\Exception\ServiceException;
9 +use Dudlewebs\WPMCS\GCP\Google\Auth\CredentialsLoader;
9 10
10 11 use Exception;
11 12
12 13 class GCloud {
@@ -19,8 +20,9 @@
19 20 protected $settings;
20 21 protected $credentials;
21 22 protected $bucket_name;
22 23 protected $bucket; // Object
24 + protected $cdnConfig;
23 25
24 26 public $service = 'gcloud';
25 27 public $gcloudClient = false;
26 28
@@ -27,23 +29,26 @@
27 29 /**
28 30 * Admin constructor.
29 31 * @since 1.0.0
30 32 */
31 - public function __construct() {
33 + public function __construct($credentials = null) {
32 34 $this->assets_url = WPMCS_ASSETS_URL;
33 35 $this->version = WPMCS_VERSION;
34 36 $this->token = WPMCS_TOKEN;
35 37
36 38 // Initialize setup
37 - $this->init();
39 + $this->init($credentials);
38 40 }
39 41
40 42 /**
41 43 * Initialise Client
44 + *
45 + * @param array|null $credentials Optional explicit credentials; falls back to
46 + * Utils::get_credentials() when omitted.
42 47 */
43 - public function init() {
48 + public function init($credentials = null) {
44 49 $this->settings = Utils::get_settings();
45 - $this->credentials = Utils::get_credentials();
50 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
46 51 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
47 52 ? $this->credentials['config']
48 53 : [];
49 54 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -51,31 +56,40 @@
51 56 : [];
52 57 $this->bucket_name = isset($this->bucketConfig['bucket_name']) && !empty($this->bucketConfig['bucket_name'])
53 58 ? $this->bucketConfig['bucket_name']
54 59 : '';
60 + $this->cdnConfig = isset($this->credentials['cdn']) && !empty($this->credentials['cdn'])
61 + ? $this->credentials['cdn']
62 + : [];
55 63
56 64 if (
57 65 isset($this->config['config_json']) && !empty($this->config['config_json']) &&
58 - isset($this->config['config_json']['path']) && !empty($this->config['config_json']['path']) &&
59 66 isset($this->bucket_name) && !empty($this->bucket_name)
60 67 ) {
61 - if(file_exists($this->config['config_json']['path'])){
68 + if(Utils::is_json($this->config['config_json'])){
62 69 // Set google client
63 - $this->gcloudClient = new StorageClient([
64 - 'keyFilePath' => $this->config['config_json']['path'],
65 - ]);
66 - // Set bucket object
67 - $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 + }
68 81 } else {
69 82 add_action('admin_notices', function (){
70 - echo wp_kses_post(sprintf( "<div class='error'><p><strong>%s: </strong><br>Google Cloud Storage configuration file missing from the directory.
71 - It may break the media url's as well as media uploads.<br>
72 - <a href='%s'>Re-configure</a> plugin to fix the issue.
73 - </p></div>",
74 - esc_html__('Media Cloud Sync', 'media-cloud-sync'),
75 - admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
76 - ));
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 + ));
77 90 });
91 +
78 92 }
79 93 }
80 94 }
81 95
@@ -83,18 +97,32 @@
83 97 * Verify Credentials
84 98 * @since 1.0.0
85 99 * @return boolean
86 100 */
87 - public function verifyCredentials( $config_file ){
88 - if (
89 - isset($config_file) && !empty($config_file) &&
90 - isset($config_file['path']) && !empty($config_file['path']) &&
91 - file_exists($config_file['path'])
92 - ) {
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 +
93 112 try {
94 - $googleClient = new StorageClient([
95 - 'keyFilePath' => $config_file['path'],
96 - ]);
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 + }
97 125
98 126 $result = [
99 127 'success' => false,
100 128 'code' => 200,
@@ -165,41 +193,50 @@
165 193 * Verify Bucket Exists
166 194 * @since 1.0.0
167 195 * @return boolean
168 196 */
169 - public function verifyBucketExist( $config_file, $bucket_name ){
170 - 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]) ) {
171 201 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
172 202 }
173 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 +
174 208 try {
175 - $googleClient = new StorageClient([
176 - 'keyFilePath' => $config_file['path'],
177 - ]);
209 + $config_array = json_decode($config_json, true);
210 + if (is_array($config_array)) {
211 + $googleClient = new StorageClient([
212 + 'keyFile' => $config_array
213 + ]);
214 + } else {
215 + return [
216 + 'success' => false,
217 + 'code' => 200,
218 + 'message' => esc_html__('JSON Configuration is invalid', 'media-cloud-sync'),
219 + ];
220 + }
178 221
179 222 try {
180 223 $bucket = $googleClient->bucket($bucket_name);
181 224
182 - // Try fetching a dummy object to test access, like you did with S3
183 - $object = $bucket->object($this->token . '_dummy-object-for-bucket-exist-check');
184 - $object->info(); // Will throw if bucket doesn't exist or access is denied
185 -
186 - return [
187 - 'message' => esc_html__('Bucket exists', 'media-cloud-sync'),
188 - 'code' => 200,
189 - 'success' => true,
190 - ];
191 - } catch (ServiceException $e) {
192 - $code = $e->getCode();
193 -
194 - if (in_array($code, [403, 404], true)) {
225 + if ($bucket->exists()) {
195 226 return [
196 227 'message' => esc_html__('Bucket exists', 'media-cloud-sync'),
197 228 'code' => 200,
198 229 'success' => true,
199 230 ];
231 + } else {
232 + return [
233 + 'message' => esc_html__('Bucket does not exist', 'media-cloud-sync'),
234 + 'code' => 200,
235 + 'success' => false,
236 + ];
200 237 }
201 -
238 + } catch (ServiceException $e) {
202 239 return [
203 240 'message' => esc_html__('Bucket does not exist or credentials are invalid: ', 'media-cloud-sync') . $e->getMessage(),
204 241 'code' => 200,
205 242 'success' => false,
@@ -204,15 +241,8 @@
204 241 'code' => 200,
205 242 'success' => false,
206 243 ];
207 244 }
208 -
209 -
210 - if($bucket_found) {
211 - return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
212 - } else {
213 - return array('message' => esc_html__("Bucket choosen does not exist / does not have read permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
214 - }
215 245 } catch (Exception $ex) {
216 246 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
217 247 }
218 248 }
@@ -222,25 +252,83 @@
222 252 * Create Bucket
223 253 * @since 1.0.0
224 254 * @return boolean
225 255 */
226 - public function createBucket( $config_file, $region, $bucket_name ){
227 - 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]) ) {
228 261 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
229 262 }
230 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 +
231 268 try {
232 - $googleClient = new StorageClient([
233 - 'keyFilePath' => $config_file['path'],
234 - ]);
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 + }
235 281
236 282 // Create Bucket
237 - $googleClient->createBucket($bucket_name, [
283 + $bucket = $googleClient->createBucket($bucket_name, [
238 284 'location' => $region,
285 + 'iamConfiguration' => [
286 + 'uniformBucketLevelAccess' => [
287 + 'enabled' => true
288 + ]
289 + ]
239 290 ]);
240 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 +
241 329 return [
242 - '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'),
243 331 'data' => [
244 332 'Name' => $bucket_name,
245 333 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
246 334 ],
@@ -248,9 +336,9 @@
248 336 'success' => true,
249 337 ];
250 338
251 339 } catch (Exception $ex) {
252 - return ['message' => $e->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
340 + return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
253 341 }
254 342 }
255 343
256 344 /**
@@ -256,18 +344,32 @@
256 344 /**
257 345 * Check Bucket Write Permission
258 346 * @since 1.0.0
259 347 */
260 - public function verifyObjectWritePermission($config_file, $bucket_name){
261 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($bucket_name) ) ) {
348 + public function verifyObjectWritePermission( $config = [], $bucketConfig = [] ) {
349 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
350 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
351 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
262 352 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
263 353 }
264 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 +
265 359 try {
266 - $googleClient = new StorageClient([
267 - 'keyFilePath' => $config_file['path'],
268 - ]);
269 -
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 + }
270 372 $bucket = $googleClient->bucket($bucket_name);
271 373 if ($bucket->exists()) {
272 374 $bucket_found = true;
273 375 } else {
@@ -273,9 +375,9 @@
273 375 } else {
274 376 return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
275 377 }
276 378 if ($bucket_found) {
277 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
379 + $object_key = Utils::get_permission_check_object_key();
278 380
279 381 // Prepare a temporary file with content to check write permission
280 382 $stream = fopen('php://temp', 'r+');
281 383 fwrite($stream, 'This is a test object to check write permission.');
@@ -285,17 +387,13 @@
285 387 $object = $bucket->upload(
286 388 $stream,
287 389 [
288 390 'name' => $object_key,
289 - // 'predefinedAcl' => 'publicRead',
290 391 ]
291 392 );
292 - if(is_resource($stream)) {
293 - fclose($stream);
294 - }
295 393
296 394 if ($object->exists()) {
297 - return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
395 + return ['message' => esc_html__('Bucket write permission verified successfully', 'media-cloud-sync'), 'code' => 200, 'success' => true];
298 396 } else {
299 397 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
300 398 }
301 399 }
@@ -300,8 +398,12 @@
300 398 }
301 399 }
302 400 } catch (Exception $ex) {
303 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 + }
304 406 }
305 407 }
306 408
307 409 /**
@@ -307,23 +409,38 @@
307 409 /**
308 410 * Check Bucket Delete Permission
309 411 * @since 1.0.0
310 412 */
311 - public function verifyObjectDeletePermission($config_file, $bucket_name){
312 - if ( !( isset($config_file['path']) && !empty($config_file['path']) && file_exists($config_file['path']) && !empty($bucket_name) ) ) {
413 + public function verifyObjectDeletePermission( $config = [], $bucketConfig = [] ) {
414 + $config_json = isset($config['config_json']) ? $config['config_json'] : '';
415 + $bucket_name = isset($bucketConfig['bucket_name']) ? $bucketConfig['bucket_name'] : '';
416 +
417 + if ( Service::has_missing_fields([$config_json, $bucket_name]) ) {
313 418 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
314 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 + }
315 423
316 424 try {
317 - $googleClient = new StorageClient([
318 - 'keyFilePath' => $config_file['path'],
319 - ]);
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 + }
320 437
321 438 $bucket = $googleClient->bucket($bucket_name);
322 439 try {
323 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
440 + $object_key = Utils::get_permission_check_object_key();
324 441
325 - // Try fetching a dummy object to test access, like you did with S3
442 + // Try fetching a dummy object to test access
326 443 $object = $bucket->object($object_key);
327 444 if ($object->exists()) {
328 445 $object->delete();
329 446 if (!$object->exists()) {
@@ -347,9 +464,9 @@
347 464 ];
348 465 }
349 466 } catch (Exception $ex) {
350 467 return [
351 - 'message' => esc_html__('Object does not exist or credentials are invalid: ', 'media-cloud-sync') . $e->getMessage(),
468 + 'message' => esc_html__('Object does not exist or credentials are invalid: ', 'media-cloud-sync') . $ex->getMessage(),
352 469 'code' => 200,
353 470 'success' => false,
354 471 ];
355 472 }
@@ -359,8 +476,108 @@
359 476 }
360 477 }
361 478
362 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 + /**
363 580 * isConfigured Function To Identify the congfigurations are correct
364 581 * @since 1.0.0
365 582 */
366 583 public function isConfigured(){
@@ -365,20 +582,23 @@
365 582 */
366 583 public function isConfigured(){
367 584 if ($this->gcloudClient) {
368 585 try {
369 - $buckets = $this->gcloudClient->buckets();
370 - if(!empty($buckets)){
371 - foreach($buckets as $bucket) {
372 - $name = $bucket->name();
373 - if (!empty($name) && $name == $this->bucket_name) {
374 - return true;
375 - }
376 - }
377 - }
378 - return false;
379 - } catch (Exception $ex) {
380 - 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 + }
381 601 }
382 602 }
383 603 return false;
384 604 }
@@ -389,13 +609,22 @@
389 609 *
390 610 */
391 611 public function toPrivate($key) {
392 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
393 614
394 - $object = $this->bucket->object($key);
395 - if ($object->exists()) {
396 - $object->update(['acl' => []], ['predefinedAcl' => 'private']);
397 - 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;
398 627 }
399 628 return false;
400 629 }
401 630
@@ -402,32 +631,342 @@
402 631
403 632 /**
404 633 * Make Object Public
405 634 * @since 1.0.0
406 - *
635 + *
407 636 */
408 637 public function toPublic($key) {
409 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
410 640
411 - $object = $this->bucket->object($key);
412 - if ($object->exists()) {
413 - $object->update(['acl' => []], ['predefinedAcl' => 'publicRead']);
414 - 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;
415 654 }
416 - return false;
417 655 }
418 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]);
419 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 +
420 686 /**
421 - * 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
422 950 * @since 1.1.8
423 951 */
424 - public function exists($key) {
952 + public function exists($key, $bucket = null) {
425 953 if(!$key) return false;
426 954
427 - $object = $this->bucket->object($key);
428 - if ($object->exists()) {
429 - 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;
430 969 }
431 970
432 971 return false;
433 972 }
@@ -433,118 +972,154 @@
433 972 }
434 973
435 974
436 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 + /**
437 1031 * Upload Single
438 1032 * @since 1.0.0
439 1033 * @return boolean
440 1034 */
441 - public function uploadSingle($media_absolute_path, $media_path, $prefix=''){
442 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
443 1036 if (
444 - isset($media_absolute_path) && !empty($media_absolute_path) &&
445 - isset($media_path) && !empty($media_path)
1037 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1038 + isset($relative_source_path) && !empty($relative_source_path)
446 1039 ) {
447 - $file_name = wp_basename( $media_path );
1040 + $file_name = wp_basename( $relative_source_path );
448 1041 if ($file_name) {
449 - $upload_path = Utils::generate_object_key($media_path, $prefix);
1042 + $upload_path = Utils::generate_object_key($relative_source_path, $prefix, $is_private);
1043 + if ($upload_path === false) {
1044 + return [
1045 + 'success' => false,
1046 + 'code' => 200,
1047 + 'message' => esc_html__('This file is marked private, but the private-media add-on is not currently active — reupload skipped to avoid exposing it.', 'media-cloud-sync')
1048 + ];
1049 + }
1050 + return $this->execute_upload($absolute_source_path, $upload_path);
1051 + }
1052 + return [
1053 + 'success' => false,
1054 + 'code' => 200,
1055 + 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
1056 + ];
1057 + }
1058 + return [
1059 + 'success' => false,
1060 + 'code' => 200,
1061 + 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
1062 + ];
1063 + }
450 1064
451 - // Decide Multipart upload or normal put object
452 - if (filesize($media_absolute_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
453 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
454 - 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 + }
455 1072
456 - $upload = $this->bucket->upload(
457 - fopen($media_absolute_path, 'r'),
458 - [
459 - 'name' => $upload_path,
460 - 'predefinedAcl' => 'publicRead',
461 - ]
462 - );
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 + }
463 1084
464 - $object = $this->bucket->object($upload_path);
1085 + try {
1086 + $handle = fopen($absolute_source_path, 'rb');
1087 + $upload = $this->bucket->upload($handle, $options);
465 1088
466 - if ($object->exists()) {
467 - $result = array(
468 - 'success' => true,
469 - 'code' => 200,
470 - 'file_url' => $this->generate_file_url($upload_path),
471 - 'key' => $upload_path,
472 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
473 - );
474 - } else {
475 - $result = array(
476 - 'success' => false,
477 - 'code' => 200,
478 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
479 - );
480 - }
481 - } catch (Exception $e) {
482 - $result = array(
483 - 'success' => false,
484 - 'code' => 200,
485 - 'message' => $e->getMessage(),
486 - );
487 - }
488 - } else {
489 - try {
490 - $upload = $this->bucket->upload(
491 - fopen($media_absolute_path, 'r'),
492 - [
493 - 'name' => $upload_path,
494 - 'predefinedAcl' => 'publicRead',
495 - 'chunkSize' => 262144 * 2,
496 - ]
497 - );
498 -
499 - $object = $this->bucket->object($upload_path);
500 -
501 - if ($object->exists()) {
502 - $result = array(
503 - 'success' => true,
504 - 'code' => 200,
505 - 'file_url' => $this->generate_file_url($upload_path),
506 - 'key' => $upload_path,
507 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
508 - );
509 - } else {
510 - $result = array(
511 - 'success' => false,
512 - 'code' => 200,
513 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
514 - );
515 - }
516 - } catch (Exception $e) {
517 - $result = array(
518 - 'success' => false,
519 - 'code' => 200,
520 - 'message' => $e->getMessage(),
521 - );
522 - }
523 - }
524 - } else {
525 - $result = array(
526 - 'success' => false,
527 - 'code' => 200,
528 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
529 - );
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 + ];
530 1097 }
531 - } else {
532 - $result = array(
533 - 'success' => false,
534 - 'code' => 200,
535 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
536 - );
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 + }
537 1113 }
538 - return $result;
539 1114 }
540 1115
541 -
542 1116 /**
543 1117 * Save object to server
544 1118 * @since 1.0.0
545 1119 */
546 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
547 1122 try {
548 1123 $object = $this->bucket->object($key);
549 1124 if ($object->exists()) {
550 1125 $object->downloadToFile($save_path);
@@ -557,9 +1132,143 @@
557 1132 }
558 1133 return false;
559 1134 }
560 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 + }
561 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 +
562 1271 /**
563 1272 * Delete Single
564 1273 * @since 1.0.0
565 1274 * @return boolean
@@ -565,8 +1274,15 @@
565 1274 * @return boolean
566 1275 */
567 1276 public function deleteSingle($key){
568 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 + }
569 1285 if (isset($key) && !empty($key)) {
570 1286 try {
571 1287 $object = $this->bucket->object($key);
572 1288 $object->delete();
@@ -602,34 +1318,41 @@
602 1318 }
603 1319
604 1320
605 1321 /**
606 - * get presigned URL
1322 + * get private URL
607 1323 * @since 1.0.0
608 1324 * @return boolean
609 1325 */
610 - public function get_presigned_url($key) {
1326 + public function get_private_url($key) {
611 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 + }
612 1335 if (isset($key) && !empty($key)) {
613 1336 try {
614 1337 $object = $this->bucket->object($key);
615 1338
616 - $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;
617 1340
618 - $presignedUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
1341 + $privateUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
619 1342
620 - if ($presignedUrl) {
1343 + if ($privateUrl) {
621 1344 $result = array(
622 1345 'success' => true,
623 1346 'code' => 200,
624 - 'file_url' => $presignedUrl,
625 - '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'),
626 1349 );
627 1350 } else {
628 1351 $result = array(
629 1352 'success' => false,
630 1353 'code' => 200,
631 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync'),
1354 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync'),
632 1355 );
633 1356 }
634 1357 } catch (Exception $e) {
635 1358 $result = array(
@@ -651,9 +1374,9 @@
651 1374
652 1375 /**
653 1376 * Generate file URL
654 1377 */
655 - private function generate_file_url($key){
1378 + public function generate_file_url($key){
656 1379 $domain = $this->get_domain();
657 1380
658 1381 return apply_filters('wpmcs_generate_google_file_url',
659 1382 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -661,8 +1384,16 @@
661 1384 $this->bucket_name
662 1385 );
663 1386 }
664 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 + }
665 1396
666 1397 /**
667 1398 * Get domain URL
668 1399 */