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 -206 1.2.31.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.');
@@ -287,14 +389,11 @@
287 389 [
288 390 'name' => $object_key,
289 391 ]
290 392 );
291 - if(is_resource($stream)) {
292 - fclose($stream);
293 - }
294 393
295 394 if ($object->exists()) {
296 - 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];
297 396 } else {
298 397 return ['message' => esc_html__('Bucket write permission not verified', 'media-cloud-sync'), 'code' => 200, 'success' => false];
299 398 }
300 399 }
@@ -299,8 +398,12 @@
299 398 }
300 399 }
301 400 } catch (Exception $ex) {
302 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 + }
303 406 }
304 407 }
305 408
306 409 /**
@@ -306,23 +409,38 @@
306 409 /**
307 410 * Check Bucket Delete Permission
308 411 * @since 1.0.0
309 412 */
310 - public function verifyObjectDeletePermission($config_file, $bucket_name){
311 - 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]) ) {
312 418 return ['message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'), 'code' => 200, 'success' => false];
313 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 + }
314 423
315 424 try {
316 - $googleClient = new StorageClient([
317 - 'keyFilePath' => $config_file['path'],
318 - ]);
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 + }
319 437
320 438 $bucket = $googleClient->bucket($bucket_name);
321 439 try {
322 - $object_key = Utils::generate_object_key($this->token . '_dummy-object-for-bucket-permission-check', '');
440 + $object_key = Utils::get_permission_check_object_key();
323 441
324 - // Try fetching a dummy object to test access, like you did with S3
442 + // Try fetching a dummy object to test access
325 443 $object = $bucket->object($object_key);
326 444 if ($object->exists()) {
327 445 $object->delete();
328 446 if (!$object->exists()) {
@@ -346,9 +464,9 @@
346 464 ];
347 465 }
348 466 } catch (Exception $ex) {
349 467 return [
350 - '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(),
351 469 'code' => 200,
352 470 'success' => false,
353 471 ];
354 472 }
@@ -358,8 +476,108 @@
358 476 }
359 477 }
360 478
361 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 + /**
362 580 * isConfigured Function To Identify the congfigurations are correct
363 581 * @since 1.0.0
364 582 */
365 583 public function isConfigured(){
@@ -364,20 +582,23 @@
364 582 */
365 583 public function isConfigured(){
366 584 if ($this->gcloudClient) {
367 585 try {
368 - $buckets = $this->gcloudClient->buckets();
369 - if(!empty($buckets)){
370 - foreach($buckets as $bucket) {
371 - $name = $bucket->name();
372 - if (!empty($name) && $name == $this->bucket_name) {
373 - return true;
374 - }
375 - }
376 - }
377 - return false;
378 - } catch (Exception $ex) {
379 - 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 + }
380 601 }
381 602 }
382 603 return false;
383 604 }
@@ -388,13 +609,22 @@
388 609 *
389 610 */
390 611 public function toPrivate($key) {
391 612 if(!$key) return false;
613 + if(!$this->bucket) return false;
392 614
393 - $object = $this->bucket->object($key);
394 - if ($object->exists()) {
395 - $object->update(['acl' => []], ['predefinedAcl' => 'private']);
396 - 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;
397 627 }
398 628 return false;
399 629 }
400 630
@@ -401,32 +631,342 @@
401 631
402 632 /**
403 633 * Make Object Public
404 634 * @since 1.0.0
405 - *
635 + *
406 636 */
407 637 public function toPublic($key) {
408 638 if(!$key) return false;
639 + if(!$this->bucket) return false;
409 640
410 - $object = $this->bucket->object($key);
411 - if ($object->exists()) {
412 - $object->update(['acl' => []], ['predefinedAcl' => 'publicRead']);
413 - 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;
414 654 }
415 - return false;
416 655 }
417 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]);
418 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 +
419 686 /**
420 - * 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
421 950 * @since 1.1.8
422 951 */
423 - public function exists($key) {
952 + public function exists($key, $bucket = null) {
424 953 if(!$key) return false;
425 954
426 - $object = $this->bucket->object($key);
427 - if ($object->exists()) {
428 - 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;
429 969 }
430 970
431 971 return false;
432 972 }
@@ -432,116 +972,154 @@
432 972 }
433 973
434 974
435 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 + /**
436 1031 * Upload Single
437 1032 * @since 1.0.0
438 1033 * @return boolean
439 1034 */
440 - public function uploadSingle($media_absolute_path, $media_path, $prefix=''){
441 - $result = array();
1035 + public function uploadSingle($absolute_source_path, $relative_source_path, $prefix='', $is_private = false){
442 1036 if (
443 - isset($media_absolute_path) && !empty($media_absolute_path) &&
444 - isset($media_path) && !empty($media_path)
1037 + isset($absolute_source_path) && !empty($absolute_source_path) &&
1038 + isset($relative_source_path) && !empty($relative_source_path)
445 1039 ) {
446 - $file_name = wp_basename( $media_path );
1040 + $file_name = wp_basename( $relative_source_path );
447 1041 if ($file_name) {
448 - $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 + }
449 1064
450 - // Decide Multipart upload or normal put object
451 - if (filesize($media_absolute_path) <= Schema::getConstant('GCLOUD_MULTIPART_MIN_FILE_SIZE')) {
452 - // Upload a publicly accessible file. The file size and type are determined by the SDK.
453 - 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 + }
454 1072
455 - $upload = $this->bucket->upload(
456 - fopen($media_absolute_path, 'r'),
457 - [
458 - 'name' => $upload_path,
459 - ]
460 - );
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 + }
461 1084
462 - $object = $this->bucket->object($upload_path);
1085 + try {
1086 + $handle = fopen($absolute_source_path, 'rb');
1087 + $upload = $this->bucket->upload($handle, $options);
463 1088
464 - if ($object->exists()) {
465 - $result = array(
466 - 'success' => true,
467 - 'code' => 200,
468 - 'file_url' => $this->generate_file_url($upload_path),
469 - 'key' => $upload_path,
470 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
471 - );
472 - } else {
473 - $result = array(
474 - 'success' => false,
475 - 'code' => 200,
476 - 'message' => esc_html__('Object not found at server.', 'media-cloud-sync'),
477 - );
478 - }
479 - } catch (Exception $e) {
480 - $result = array(
481 - 'success' => false,
482 - 'code' => 200,
483 - 'message' => $e->getMessage(),
484 - );
485 - }
486 - } else {
487 - try {
488 - $upload = $this->bucket->upload(
489 - fopen($media_absolute_path, 'r'),
490 - [
491 - 'name' => $upload_path,
492 - 'chunkSize' => 262144 * 2,
493 - ]
494 - );
495 -
496 - $object = $this->bucket->object($upload_path);
497 -
498 - if ($object->exists()) {
499 - $result = array(
500 - 'success' => true,
501 - 'code' => 200,
502 - 'file_url' => $this->generate_file_url($upload_path),
503 - 'key' => $upload_path,
504 - 'message' => esc_html__('File Uploaded Successfully', 'media-cloud-sync'),
505 - );
506 - } else {
507 - $result = array(
508 - 'success' => false,
509 - 'code' => 200,
510 - 'message' => esc_html__('Something happened while uploading to server', 'media-cloud-sync'),
511 - );
512 - }
513 - } catch (Exception $e) {
514 - $result = array(
515 - 'success' => false,
516 - 'code' => 200,
517 - 'message' => $e->getMessage(),
518 - );
519 - }
520 - }
521 - } else {
522 - $result = array(
523 - 'success' => false,
524 - 'code' => 200,
525 - 'message' => esc_html__('Check the file you are trying to upload. Please try again', 'media-cloud-sync'),
526 - );
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 + ];
527 1097 }
528 - } else {
529 - $result = array(
530 - 'success' => false,
531 - 'code' => 200,
532 - 'message' => esc_html__('Insufficient Data. Please try again', 'media-cloud-sync'),
533 - );
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 + }
534 1113 }
535 - return $result;
536 1114 }
537 1115
538 -
539 1116 /**
540 1117 * Save object to server
541 1118 * @since 1.0.0
542 1119 */
543 1120 public function object_to_server($key, $save_path){
1121 + if(!$this->bucket) return false;
544 1122 try {
545 1123 $object = $this->bucket->object($key);
546 1124 if ($object->exists()) {
547 1125 $object->downloadToFile($save_path);
@@ -554,9 +1132,143 @@
554 1132 }
555 1133 return false;
556 1134 }
557 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 + }
558 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 +
559 1271 /**
560 1272 * Delete Single
561 1273 * @since 1.0.0
562 1274 * @return boolean
@@ -562,8 +1274,15 @@
562 1274 * @return boolean
563 1275 */
564 1276 public function deleteSingle($key){
565 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 + }
566 1285 if (isset($key) && !empty($key)) {
567 1286 try {
568 1287 $object = $this->bucket->object($key);
569 1288 $object->delete();
@@ -599,34 +1318,41 @@
599 1318 }
600 1319
601 1320
602 1321 /**
603 - * get presigned URL
1322 + * get private URL
604 1323 * @since 1.0.0
605 1324 * @return boolean
606 1325 */
607 - public function get_presigned_url($key) {
1326 + public function get_private_url($key) {
608 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 + }
609 1335 if (isset($key) && !empty($key)) {
610 1336 try {
611 1337 $object = $this->bucket->object($key);
612 1338
613 - $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;
614 1340
615 - $presignedUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
1341 + $privateUrl = $object->signedUrl(new \DateTime(sprintf('+%s minutes', $expires)));
616 1342
617 - if ($presignedUrl) {
1343 + if ($privateUrl) {
618 1344 $result = array(
619 1345 'success' => true,
620 1346 'code' => 200,
621 - 'file_url' => $presignedUrl,
622 - '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'),
623 1349 );
624 1350 } else {
625 1351 $result = array(
626 1352 'success' => false,
627 1353 'code' => 200,
628 - 'message' => esc_html__('Error getting presigned URL', 'media-cloud-sync'),
1354 + 'message' => esc_html__('Error getting private URL', 'media-cloud-sync'),
629 1355 );
630 1356 }
631 1357 } catch (Exception $e) {
632 1358 $result = array(
@@ -648,9 +1374,9 @@
648 1374
649 1375 /**
650 1376 * Generate file URL
651 1377 */
652 - private function generate_file_url($key){
1378 + public function generate_file_url($key){
653 1379 $domain = $this->get_domain();
654 1380
655 1381 return apply_filters('wpmcs_generate_google_file_url',
656 1382 $domain . '/' . $this->bucket_name . '/' . $key,
@@ -658,8 +1384,16 @@
658 1384 $this->bucket_name
659 1385 );
660 1386 }
661 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 + }
662 1396
663 1397 /**
664 1398 * Get domain URL
665 1399 */