PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/base/services/gcloud.php +800 -198 1.2.5 → 1.4.2 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 {
@@ -28,23 +29,26 @@
28 29 /**
29 30 * Admin constructor.
30 31 * @since 1.0.0
31 32 */
32 - public function __construct() {
33 + public function __construct($credentials = null) {
33 34 $this->assets_url = WPMCS_ASSETS_URL;
34 35 $this->version = WPMCS_VERSION;
35 36 $this->token = WPMCS_TOKEN;
36 37
37 38 // Initialize setup
38 - $this->init();
39 + $this->init($credentials);
39 40 }
40 41
41 42 /**
42 43 * Initialise Client
44 + *
45 + * @param array|null $credentials Optional explicit credentials; falls back to
46 + * Utils::get_credentials() when omitted.
43 47 */
44 - public function init() {
48 + public function init($credentials = null) {
45 49 $this->settings = Utils::get_settings();
46 - $this->credentials = Utils::get_credentials();
50 + $this->credentials = $credentials !== null ? $credentials : Utils::get_credentials();
47 51 $this->config = isset($this->credentials['config']) && !empty($this->credentials['config'])
48 52 ? $this->credentials['config']
49 53 : [];
50 54 $this->bucketConfig = isset($this->credentials['bucketConfig']) && !empty($this->credentials['bucketConfig'])
@@ -58,28 +62,34 @@
58 62 : [];
59 63
60 64 if (
61 65 isset($this->config['config_json']) && !empty($this->config['config_json']) &&
62 - isset($this->config['config_json']['path']) && !empty($this->config['config_json']['path']) &&
63 66 isset($this->bucket_name) && !empty($this->bucket_name)
64 67 ) {
65 - if(file_exists($this->config['config_json']['path'])){
68 + if(Utils::is_json($this->config['config_json'])){
66 69 // Set google client
67 - $this->gcloudClient = new StorageClient([
68 - 'keyFilePath' => $this->config['config_json']['path'],
69 - ]);
70 - // Set bucket object
71 - $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 + }
72 81 } else {
73 82 add_action('admin_notices', function (){
74 - echo wp_kses_post(sprintf( "<div class='error'><p><strong>%s: </strong><br>Google Cloud Storage configuration file missing from the directory.
75 - It may break the media url's as well as media uploads.<br>
76 - <a href='%s'>Re-configure</a> plugin to fix the issue.
77 - </p></div>",
78 - esc_html__('Media Cloud Sync', 'media-cloud-sync'),
79 - admin_url('admin.php?page='.$this->token . '-admin-ui#/configure')
80 - ));
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 + ));
81 90 });
91 +
82 92 }
83 93 }
84 94 }
85 95
@@ -87,18 +97,32 @@
87 97 * Verify Credentials
88 98 * @since 1.0.0
89 99 * @return boolean
90 100 */
91 - public function verifyCredentials( $config_file ){
92 - if (
93 - isset($config_file) && !empty($config_file) &&
94 - isset($config_file['path']) && !empty($config_file['path']) &&
95 - file_exists($config_file['path'])
96 - ) {
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 +
97 112 try {
98 - $googleClient = new StorageClient([
99 - 'keyFilePath' => $config_file['path'],
100 - ]);
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 + }
101 125
102 126 $result = [
103 127 'success' => false,
104 128 'code' => 200,
@@ -169,17 +193,32 @@
169 193 * Verify Bucket Exists
170 194 * @since 1.0.0
171 195 * @return boolean
172 196 */
173 - public function verifyBucketExist( $config_file, $bucket_name ){
174 - 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]) ) {
175 201 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
176 202 }
177 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 +
178 208 try {
179 - $googleClient = new StorageClient([
180 - 'keyFilePath' => $config_file['path'],
181 - ]);
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 + }
182 221
183 222 try {
184 223 $bucket = $googleClient->bucket($bucket_name);
185 224
@@ -202,15 +241,8 @@
202 241 'code' => 200,
203 242 'success' => false,
204 243 ];
205 244 }
206 -
207 -
208 - if($bucket_found) {
209 - return array('message' => esc_html__('Bucket exist', 'media-cloud-sync'), 'code' => 200, 'success' => true);
210 - } else {
211 - return array('message' => esc_html__("Bucket choosen does not exist / does not have read permission", 'media-cloud-sync'), 'code' => 200, 'success' => false);
212 - }
213 245 } catch (Exception $ex) {
214 246 return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
215 247 }
216 248 }
@@ -220,17 +252,33 @@
220 252 * Create Bucket
221 253 * @since 1.0.0
222 254 * @return boolean
223 255 */
224 - public function createBucket( $config_file, $region, $bucket_name ){
225 - 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]) ) {
226 261 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
227 262 }
228 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 +
229 268 try {
230 - $googleClient = new StorageClient([
231 - 'keyFilePath' => $config_file['path'],
232 - ]);
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 + }
233 281
234 282 // Create Bucket
235 283 $bucket = $googleClient->createBucket($bucket_name, [
236 284 'location' => $region,
@@ -240,22 +288,47 @@
240 288 ]
241 289 ]
242 290 ]);
243 291
244 - $iam = $bucket->iam();
245 - $policy = $iam->policy();
292 + // Fetch the bucket's IAM
293 + try {
294 + $iam = $bucket->iam();
246 295
247 - // Add allUsers as a Storage Object Viewer
248 - $policy['bindings'][] = [
249 - 'role' => 'roles/storage.objectViewer',
250 - 'members' => ['allUsers'],
251 - ];
296 + $policy = $iam->policy();
252 297
253 - // Set the updated policy
254 - $iam->setPolicy($policy);
298 + // Add allUsers as a Storage Object Viewer
299 + $policy['bindings'][] = [
300 + 'role' => 'roles/storage.objectViewer',
301 + 'members' => ['allUsers'],
302 + ];
255 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 +
256 329 return [
257 - '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'),
258 331 'data' => [
259 332 'Name' => $bucket_name,
260 333 'CreationDate' => date('Y-m-d\TH:i:s\Z'),
261 334 ],
@@ -263,9 +336,9 @@
263 336 'success' => true,
264 337 ];
265 338
266 339 } catch (Exception $ex) {
267 - 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];
268 341 }
269 342 }
270 343
271 344 /**
@@ -271,18 +344,32 @@
271 344 /**
272 345 * Check Bucket Write Permission
273 346 * @since 1.0.0
274 347 */
275 - public function verifyObjectWritePermission($config_file, $bucket_name){
276 - 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]) ) {
277 352 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
278 353 }
279 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 +
280 359 try {
281 - $googleClient = new StorageClient([
282 - 'keyFilePath' => $config_file['path'],
283 - ]);
284 -
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 + }
285 372 $bucket = $googleClient->bucket($bucket_name);
286 373 if ($bucket->exists()) {
287 374 $bucket_found = true;
288 375 } else {
@@ -288,9 +375,9 @@
288 375 } else {
289 376 return ['message' => esc_html__('No Buckets found', 'media-cloud-sync'), 'code' => 200, 'success' => false];
290 377 }
291 378 if ($bucket_found) {
292 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
379 + $object_key = Utils::get_permission_check_object_key();
293 380
294 381 // Prepare a temporary file with content to check write permission
295 382 $stream = fopen('php://temp', 'r+');
296 383 fwrite($stream, 'This is a test object to check write permission.');
@@ -302,14 +389,11 @@
302 389 [
303 390 'name' => $object_key,
304 391 ]
305 392 );
306 - if(is_resource($stream)) {
307 - fclose($stream);
308 - }
309 393
310 394 if ($object->exists()) {
311 - 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];
312 396 } else {
313 397 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
314 398 }
315 399 }
@@ -314,8 +398,12 @@
314 398 }
315 399 }
316 400 } catch (Exception $ex) {
317 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 + }
318 406 }
319 407 }
320 408
321 409 /**
@@ -321,23 +409,38 @@
321 409 /**
322 410 * Check Bucket Delete Permission
323 411 * @since 1.0.0
324 412 */
325 - public function verifyObjectDeletePermission($config_file, $bucket_name){
326 - 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]) ) {
327 418 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
328 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 + }
329 423
330 424 try {
331 - $googleClient = new StorageClient([
332 - 'keyFilePath' => $config_file['path'],
333 - ]);
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 + }
334 437
335 438 $bucket = $googleClient->bucket($bucket_name);
336 439 try {
337 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
440 + $object_key = Utils::get_permission_check_object_key();
338 441
339 - // Try fetching a dummy object to test access, like you did with S3
442 + // Try fetching a dummy object to test access
340 443 $object = $bucket->object($object_key);
341 444 if ($object->exists()) {
342 445 $object->delete();
343 446 if (!$object->exists()) {
@@ -361,9 +464,9 @@
361 464 ];
362 465 }
363 466 } catch (Exception $ex) {
364 467 return [
365 - '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(),
366 469 'code' => 200,
367 470 'success' => false,
368 471 ];
369 472 }
@@ -382,11 +485,10 @@
382 485 'status' => false,
383 486 'message' => '',
384 487 'lastChecked' => time(),
385 488 ];
386 - if (empty($this->gcloudClient) || empty($this->bucket_name)) {
489 + if (Service::has_missing_fields([$this->gcloudClient, $this->bucket_name])) {
387 490 $result['message'] = esc_html__('Please check the authorization details', 'media-cloud-sync');
388 - Utils::set_status('cdnRead', $result);
389 491 return [
390 492 'message' => $result['message'],
391 493 'code' => 200,
392 494 'success' => false,
@@ -394,12 +496,12 @@
394 496 ];
395 497 }
396 498
397 499 try {
398 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
500 + $object_key = Utils::get_permission_check_object_key();
399 501
400 502 // Check if the object was created successfully
401 - if (!$this->bucket->object($object_key)->exists()) {
503 + if (!$this->exists($object_key)) {
402 504 // Create a dummy object to check write permission
403 505 $stream = fopen('php://temp', 'r+');
404 506 fwrite($stream, 'This is a test object to check read permission.');
405 507 rewind($stream);
@@ -406,18 +508,15 @@
406 508 $this->bucket->upload(
407 509 $stream,
408 510 [
409 511 'name' => $object_key,
512 + 'metadata' => ['cacheControl' => 'no-cache, no-store, must-revalidate'],
410 513 ]
411 514 );
412 - if (is_resource($stream)) {
413 - fclose($stream);
414 - }
415 515 // Re-check if the object was created successfully
416 - if (!$this->bucket->object($object_key)->exists()) {
516 + if (!$this->exists($object_key)) {
417 517 $result['status'] = false;
418 518 $result['message'] = esc_html__('Failed to create an object for read permission check, please check service configuration', 'media-cloud-sync');
419 - Utils::set_status('cdnRead', $result);
420 519 return [
421 520 'message' => $result['message'],
422 521 'code' => 200,
423 522 'success' => false,
@@ -428,24 +527,30 @@
428 527
429 528 $url = $this->generate_file_url($object_key);
430 529 $cdn_url = Cdn::may_generate_cdn_url($url, $object_key);
431 530
432 - $headers = @get_headers($cdn_url);
433 - if (strpos($headers[0], '200') !== false) {
531 + // Never trust a cached response for this fixed, predictable URL — a stale cached
532 + // error would otherwise keep failing the check long after real access is fine.
533 + $no_cache_context = stream_context_create(['http' => ['header' => "Cache-Control: no-cache\r\nPragma: no-cache\r\n"]]);
534 + $headers = @get_headers($cdn_url, false, $no_cache_context);
535 + $status_code = (is_array($headers) && !empty($headers[0]) && preg_match('/\s(\d{3})\s/', $headers[0], $matches))
536 + ? (int) $matches[1]
537 + : 0;
538 +
539 + if ($status_code === 200) {
434 540 $result['status'] = true;
435 541 $result['message'] = esc_html__('Objects are accessible to Read', 'media-cloud-sync');
436 - } else if (strpos($headers[0], '403') !== false) {
542 + } else if ($status_code === 403) {
437 543 $result['status'] = false;
438 - if($this->cdnConfig['service'] == $this->service) {
544 + if(isset($this->cdnConfig['service']) && $this->cdnConfig['service'] == $this->service) {
439 545 $result['message'] = esc_html__('Access Denied. Please check your bucket policy. Public Read Access is required.', 'media-cloud-sync');
440 546 } else {
441 547 $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
442 548 }
443 - $result['message'] = esc_html__('Access Denied. Please check your bucket policy', 'media-cloud-sync');
444 - } else if (strpos($headers[0], '404') !== false) {
549 + } else if ($status_code === 404) {
445 550 $result['status'] = false;
446 551 $result['message'] = esc_html__('Object not found. Please check your bucket policy', 'media-cloud-sync');
447 - } else if (strpos($headers[0], '500') !== false) {
552 + } else if ($status_code === 500) {
448 553 $result['status'] = false;
449 554 $result['message'] = esc_html__('Internal Server error. Please check your bucket policy', 'media-cloud-sync');
450 555 } else {
451 556 $result['status'] = false;
@@ -450,9 +555,8 @@
450 555 } else {
451 556 $result['status'] = false;
452 557 $result['message'] = esc_html__('Objects are not accessible to read', 'media-cloud-sync');
453 558 }
454 - Utils::set_status('cdnRead', $result);
455 559 $this->deleteSingle($object_key);
456 560 return [
457 561 'message' => $result['message'],
458 562 'code' => 200,
@@ -459,11 +563,17 @@
459 563 'success' => $result['status'],
460 564 'lastChecked' => $result['lastChecked'],
461 565 ];
462 566 } catch (ServiceException $ex) {
463 - return ['message' => $ex->getMessage(), 'code' => 200, 'success' => false];
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()];
464 569 } catch (Exception $ex) {
465 - return ['message' => $ex->getMessage() ?? esc_html__('Please check the authorization details', 'media-cloud-sync'), 'code' => 200, 'success' => false];
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 + }
466 576 }
467 577 }
468 578
469 579 /**
@@ -474,9 +584,9 @@
474 584 if ($this->gcloudClient) {
475 585 try {
476 586 $bucket = $this->gcloudClient->bucket($this->token . '_dummy-bucket-for-auth-check');
477 587 $exists = $bucket->exists(); // Triggers the API call
478 - return false;
588 + return true;
479 589 } catch (ServiceException $e) {
480 590 $statusCode = $e->getCode();
481 591
482 592 $validErrors = [200, 403, 404];
@@ -499,8 +609,9 @@
499 609 *
500 610 */
501 611 public function toPrivate($key) {
502 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
503 614
504 615 try {
505 616 $object = $this->bucket->object($key);
506 617 if ($object->exists()) {
@@ -520,12 +631,13 @@
520 631
521 632 /**
522 633 * Make Object Public
523 634 * @since 1.0.0
524 - *
635 + *
525 636 */
526 637 public function toPublic($key) {
527 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
528 640
529 641 try {
530 642 $object = $this->bucket->object($key);
531 643 if ($object->exists()) {
@@ -541,18 +653,309 @@
541 653 return false;
542 654 }
543 655 }
544 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]);
545 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 +
546 686 /**
547 - * 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
548 950 * @since 1.1.8
549 951 */
550 - public function exists($key) {
952 + public function exists($key, $bucket = null) {
551 953 if(!$key) return false;
552 954
553 955 try {
554 - $object = $this->bucket->object($key);
956 + $bucket = $bucket ?? $this->bucket;
957 + $object = $bucket->object($key);
555 958 if ($object->exists()) {
556 959 return true;
557 960 } else {
558 961 return false;
@@ -569,116 +972,154 @@
569 972 }
570 973
571 974
572 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 + /**
573 1031 * Upload Single
574 1032 * @since 1.0.0
575 1033 * @return boolean
576 1034 */
577 - public function uploadSingle($media_absolute_path, $media_path, $prefix=''){
578 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
579 1036 if (
580 - isset($media_absolute_path) && !empty($media_absolute_path) &&
581 - isset($media_path) && !empty($media_path)
1037 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1038 + isset($relative_source_path) && !empty($relative_source_path)
582 1039 ) {
583 - $file_name = wp_basename( $media_path );
1040 + $file_name = wp_basename( $relative_source_path );
584 1041 if ($file_name) {
585 - $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 + }
586 1064
587 - // Decide Multipart upload or normal put object
588 - if (filesize($media_absolute_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
589 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
590 - 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 + }
591 1072
592 - $upload = $this->bucket->upload(
593 - fopen($media_absolute_path, 'r'),
594 - [
595 - 'name' => $upload_path,
596 - ]
597 - );
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 + }
598 1084
599 - $object = $this->bucket->object($upload_path);
1085 + try {
1086 + $handle = fopen($absolute_source_path, 'rb');
1087 + $upload = $this->bucket->upload($handle, $options);
600 1088
601 - if ($object->exists()) {
602 - $result = array(
603 - 'success' => true,
604 - 'code' => 200,
605 - 'file_url' => $this->generate_file_url($upload_path),
606 - 'key' => $upload_path,
607 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
608 - );
609 - } else {
610 - $result = array(
611 - 'success' => false,
612 - 'code' => 200,
613 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
614 - );
615 - }
616 - } catch (Exception $e) {
617 - $result = array(
618 - 'success' => false,
619 - 'code' => 200,
620 - 'message' => $e->getMessage(),
621 - );
622 - }
623 - } else {
624 - try {
625 - $upload = $this->bucket->upload(
626 - fopen($media_absolute_path, 'r'),
627 - [
628 - 'name' => $upload_path,
629 - 'chunkSize' => 262144 * 2,
630 - ]
631 - );
632 -
633 - $object = $this->bucket->object($upload_path);
634 -
635 - if ($object->exists()) {
636 - $result = array(
637 - 'success' => true,
638 - 'code' => 200,
639 - 'file_url' => $this->generate_file_url($upload_path),
640 - 'key' => $upload_path,
641 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
642 - );
643 - } else {
644 - $result = array(
645 - 'success' => false,
646 - 'code' => 200,
647 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
648 - );
649 - }
650 - } catch (Exception $e) {
651 - $result = array(
652 - 'success' => false,
653 - 'code' => 200,
654 - 'message' => $e->getMessage(),
655 - );
656 - }
657 - }
658 - } else {
659 - $result = array(
660 - 'success' => false,
661 - 'code' => 200,
662 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
663 - );
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 + ];
664 1097 }
665 - } else {
666 - $result = array(
667 - 'success' => false,
668 - 'code' => 200,
669 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
670 - );
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 + }
671 1113 }
672 - return $result;
673 1114 }
674 1115
675 -
676 1116 /**
677 1117 * Save object to server
678 1118 * @since 1.0.0
679 1119 */
680 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
681 1122 try {
682 1123 $object = $this->bucket->object($key);
683 1124 if ($object->exists()) {
684 1125 $object->downloadToFile($save_path);
@@ -691,9 +1132,143 @@
691 1132 }
692 1133 return false;
693 1134 }
694 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 + }
695 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 +
696 1271 /**
697 1272 * Delete Single
698 1273 * @since 1.0.0
699 1274 * @return boolean
@@ -699,8 +1274,15 @@
699 1274 * @return boolean
700 1275 */
701 1276 public function deleteSingle($key){
702 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 + }
703 1285 if (isset($key) && !empty($key)) {
704 1286 try {
705 1287 $object = $this->bucket->object($key);
706 1288 $object->delete();
@@ -736,34 +1318,41 @@
736 1318 }
737 1319
738 1320
739 1321 /**
740 - * get presigned URL
1322 + * get private URL
741 1323 * @since 1.0.0
742 1324 * @return boolean
743 1325 */
744 - public function get_presigned_url($key) {
1326 + public function get_private_url($key) {
745 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 + }
746 1335 if (isset($key) && !empty($key)) {
747 1336 try {
748 1337 $object = $this->bucket->object($key);
749 1338
750 - $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;
751 1340
752 - $presignedUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
1341 + $privateUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
753 1342
754 - if ($presignedUrl) {
1343 + if ($privateUrl) {
755 1344 $result = array(
756 1345 'success' => true,
757 1346 'code' => 200,
758 - 'file_url' => $presignedUrl,
759 - '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'),
760 1349 );
761 1350 } else {
762 1351 $result = array(
763 1352 'success' => false,
764 1353 'code' => 200,
765 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync'),
1354 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync'),
766 1355 );
767 1356 }
768 1357 } catch (Exception $e) {
769 1358 $result = array(
@@ -785,9 +1374,9 @@
785 1374
786 1375 /**
787 1376 * Generate file URL
788 1377 */
789 - private function generate_file_url($key){
1378 + public function generate_file_url($key){
790 1379 $domain = $this->get_domain();
791 1380
792 1381 return apply_filters('wpmcs_generate_google_file_url',
793 1382 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -795,8 +1384,16 @@
795 1384 $this->bucket_name
796 1385 );
797 1386 }
798 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 + }
799 1396
800 1397 /**
801 1398 * Get domain URL
802 1399 */
@@ -801,8 +1398,13 @@
801 1398 * Get domain URL
802 1399 */
803 1400 public function get_domain() {
804 1401 $url_base = 'https://storage.googleapis.com';
805 -
1402 +
806 1403 return $url_base;
1404 + }
1405 +
1406 + /** Exposes the already-constructed SDK client for StreamWrapper's registration — avoids reconstructing one from credentials. */
1407 + public function get_client() {
1408 + return $this->gcloudClient;
807 1409 }
808 1410 }