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
media-cloud-sync / includes / config / utils.php

utils.php in Media Cloud Sync 1.4.1, at includes/config/utils.php

1,323 lines 43.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Dudlewebs\WPMCS;
4
5 defined('ABSPATH') || exit;
6
7 class Utils {
8 /**
9 * Check a variable is empty
10 * @since 1.0.0
11 * @param string|integer|array|float
12 * @return boolean
13 */
14 public static function is_empty($var){
15 if (is_array($var)) {
16 return empty($var);
17 } else {
18 return ($var === null || $var === false || $var === '');
19 }
20 }
21
22 /**
23 * Function To get Plugin Specific Wordpress Option
24 * @since 1.0.0
25 * @return array|boolean|string|integer|float|double
26 */
27 public static function get_option($key, $default = false, $meta_name = false, $expire = false){
28 $data = Cache::get_object_cache( $key, false, $meta_name, $expire );
29 return $data === false ? $default : $data;
30 }
31
32 /**
33 * Function To update Plugin Specific Wordpress Option
34 * @since 1.0.0
35 * @return boolean
36 */
37 public static function update_option($key, $options, $meta_name = false, $expire = false){
38 return Cache::set_object_cache( $key, $options, false, $meta_name, $expire );
39 }
40
41 /**
42 * Function To delete Plugin Specific Wordpress Option
43 * @since 1.0.0
44 * @return boolean
45 */
46 public static function delete_option($key, $meta_name = false){
47 return Cache::delete_object_cache( $key, false, $meta_name );
48 }
49
50 /**
51 * Function To get Plugin Specific Wordpress post meta
52 * @since 1.0.0
53 * @return array|boolean|string|integer|float|double
54 */
55 public static function get_meta($post_id, $key, $default = false, $meta_name = false, $expire = false){
56 $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire );
57 return $data === false ? $default : $data;
58 }
59
60 /**
61 * Get Post Meta Data By Query
62 * @since 1.0.0
63 * @return boolean
64 */
65 public static function get_post_meta($post_id, $key, $single=false, $db_query=false){
66 global $wpdb;
67 if(!(!empty($key) || $post_id)) return false;
68
69 if($db_query) {
70 $meta_data = $wpdb->get_row( $wpdb->prepare( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=%d AND meta_key=%s", $post_id, $key ) );
71 if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
72 return false;
73 }
74 return $meta_data->meta_value;
75 } else {
76 return get_post_meta( $post_id, $key, $single );
77 }
78 }
79
80 /**
81 * Get Option Data By Query
82 * @since 1.0.0
83 * @return boolean
84 */
85 public static function get_option_meta($key, $single=false, $db_query=false){
86 global $wpdb;
87 if(!(!empty($key))) return false;
88
89 if($db_query) {
90 $meta_data = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name=%s", $key ) );
91 if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
92 return false;
93 }
94 return $meta_data->option_value;
95 } else {
96 return get_option( $key, $single );
97 }
98 }
99
100
101 /**
102 * Function To update Plugin Specific Wordpress post meta
103 * @since 1.0.0
104 * @return boolean
105 */
106 public static function update_meta($post_id, $key, $options, $meta_name = false, $expire = false){
107 return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire );
108 }
109
110 /**
111 * Function To delete Plugin Specific Wordpress post meta
112 * @since 1.0.0
113 * @return boolean
114 */
115 public static function delete_meta($post_id, $key, $meta_name = false){
116 return Cache::delete_object_cache( $key, $post_id, $meta_name );
117 }
118
119 /**
120 * Function To get Plugin Specific Wordpress user meta
121 * @since 1.0.0
122 * @return array|boolean|string|integer|float|double
123 */
124 public static function get_user_meta($post_id, $key, $default = false, $meta_name = false, $expire = false){
125 $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, 'user' );
126 return $data === false ? $default : $data;
127 }
128
129 /**
130 * Function To update Plugin Specific Wordpress user meta
131 * @since 1.0.0
132 * @return boolean
133 */
134 public static function update_user_meta($post_id, $key, $options, $meta_name = false, $expire = false){
135 return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, 'user' );
136 }
137
138 /**
139 * Function To delete Plugin Specific Wordpress user meta
140 * @since 1.0.0
141 * @return boolean
142 */
143 public static function delete_user_meta($post_id, $key, $meta_name = false){
144 return Cache::delete_object_cache( $key, $post_id, $meta_name, 'user' );
145 }
146
147 /**
148 * Function To get Plugin Specific meta via a caller-supplied storage backend
149 * — for a meta table that isn't 'posts'/'users' and doesn't follow WP's
150 * standard get_metadata() column conventions (e.g. BuddyBoss's groupmeta,
151 * which uses its own get/update/delete functions internally).
152 * @param array $backend ['get'=>callable, 'update'=>callable, 'delete'=>callable, 'prefix'=>string]
153 * Each callable is shaped like get_post_meta($id,$key,true)/
154 * update_post_meta($id,$key,$value)/delete_post_meta($id,$key).
155 * @since 1.4.0.3
156 * @return array|boolean|string|integer|float|double
157 */
158 public static function get_custom_meta($post_id, $key, $default = false, $meta_name = false, $expire = false, $backend = []){
159 $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, $backend );
160 return $data === false ? $default : $data;
161 }
162
163 /**
164 * Function To update Plugin Specific meta via a caller-supplied storage backend. See get_custom_meta().
165 * @since 1.4.0.3
166 * @return boolean
167 */
168 public static function update_custom_meta($post_id, $key, $options, $meta_name = false, $expire = false, $backend = []){
169 return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, $backend );
170 }
171
172 /**
173 * Function To delete Plugin Specific meta via a caller-supplied storage backend. See get_custom_meta().
174 * @since 1.4.0.3
175 * @return boolean
176 */
177 public static function delete_custom_meta($post_id, $key, $meta_name = false, $backend = []){
178 return Cache::delete_object_cache( $key, $post_id, $meta_name, $backend );
179 }
180
181
182 /**
183 * Clear meta from database
184 *
185 * @param string|false $meta_name
186 * @param string $meta_table
187 * @param bool $flush_cache Whether to flush the plugin object cache afterward.
188 */
189 public static function clear_all_meta($meta_name = false, $meta_table = 'all', $flush_cache = true) {
190 global $wpdb;
191
192 $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('META_KEY') : $meta_name;
193
194 if ( empty( $meta_name ) ) {
195 return false; // Avoid accidental deletions if the meta_key is empty
196 }
197
198 $meta_tables = $meta_table == 'all' ? ['postmeta', 'usermeta', 'options'] : [$meta_table];
199
200 if( in_array('postmeta', $meta_tables) ) {
201 // Clear post meta
202 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
203 }
204
205 if( in_array('usermeta', $meta_tables) ) {
206 // Clear user meta
207 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", $meta_name ) );
208 }
209
210 if( in_array('options', $meta_tables) ) {
211 // Clear options
212 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $meta_name ) );
213 }
214
215 self::invalidate_core_meta_cache($meta_tables, $meta_name);
216
217 if ($flush_cache) {
218 Cache::flush_object_cache();
219 }
220
221 return true;
222 }
223
224 /**
225 * Clears all content meta from the database
226 *
227 * @param string|false $meta_name Optional. The meta key to clear. Defaults to the constant CONTENT_META_KEY.
228 * @param bool $flush_cache Whether to flush the plugin object cache afterward.
229 */
230 public static function clear_all_content_meta($meta_name = false, $flush_cache = true) {
231 global $wpdb;
232 $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('CONTENT_META_KEY') : $meta_name;
233
234 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
235
236 self::invalidate_core_meta_cache(['postmeta'], $meta_name);
237
238 if ($flush_cache) {
239 Cache::flush_object_cache();
240 }
241
242 return true;
243 }
244
245 /**
246 * Invalidate WordPress core object caches after direct SQL meta deletes.
247 */
248 private static function invalidate_core_meta_cache($meta_tables, $meta_name) {
249 if (!function_exists('wp_cache_delete')) {
250 return;
251 }
252
253 if (in_array('options', $meta_tables, true)) {
254 wp_cache_delete('alloptions', 'options');
255 wp_cache_delete($meta_name, 'options');
256 }
257
258 if (in_array('postmeta', $meta_tables, true)) {
259 if (function_exists('wp_cache_set_last_changed')) {
260 wp_cache_set_last_changed('posts');
261 } elseif (function_exists('wp_cache_delete')) {
262 wp_cache_delete('last_changed', 'posts');
263 }
264 }
265 }
266
267
268
269 /**
270 * Function To get Current credentials
271 * @since 1.0.0
272 * @return array|boolean|string|integer|float|double
273 */
274 public static function get_credentials($option='', $default=false, $masked_config = false){
275 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
276 if(isset($current_setttings) && !empty($current_setttings)){
277 // Resolve the credential source. Defaults to 'database' for backward compatibility.
278 $source = isset($current_setttings['configSource']) ? $current_setttings['configSource'] : 'database';
279
280 if($source === 'config') {
281 // Credentials live in the WPMCS_CONFIG constant (wp-config.php).
282 // Server-side consumers receive the real values; REST-facing (masked) callers
283 // receive nothing so the constant contents are never exposed to the browser.
284 $current_setttings['config'] = $masked_config ? [] : self::get_wp_config_credentials();
285 } elseif($masked_config && isset($current_setttings['config'])) {
286 $current_setttings['config'] = self::mask_config($current_setttings['config']);
287 }
288
289 if(isset($option) && !empty($option)){
290 if(isset($current_setttings[$option])) {
291 return $current_setttings[$option];
292 } else {
293 return $default;
294 }
295 } else {
296 return $current_setttings;
297 }
298 } else {
299 return $default;
300 }
301 }
302
303 /**
304 * Check whether credentials are defined via a wp-config.php constant.
305 * @since 1.3.11
306 * @param string $constant
307 * @return boolean
308 */
309 public static function is_wp_config_credentials_defined($constant = 'WPMCS_CONFIG'){
310 return defined($constant);
311 }
312
313 /**
314 * Get credentials defined via a wp-config.php constant. Accepts a PHP array or serialized string.
315 * @since 1.3.11
316 * @param string $constant
317 * @return array
318 */
319 public static function get_wp_config_credentials($constant = 'WPMCS_CONFIG'){
320 if(!defined($constant)) {
321 return [];
322 }
323 $config = constant($constant);
324 if(is_string($config)) {
325 $config = self::maybe_unserialize($config);
326 }
327 return is_array($config) ? $config : [];
328 }
329
330 /**
331 * Get the current credential source ('database' | 'config').
332 * Defaults to 'database' for backward compatibility.
333 * @since 1.3.11
334 * @return string
335 */
336 public static function get_credentials_source(){
337 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
338 return isset($current_setttings['configSource']) ? $current_setttings['configSource'] : 'database';
339 }
340
341 /**
342 * Mask Config
343 * @since 1.2.13
344 * @return array|boolean|string|integer|float|double
345 */
346 public static function mask_config($config){
347 foreach ($config as $key => $value) {
348 if (in_array($key, ['config_json', 'secret_key'])) {
349 $config[$key] = substr($value, 0, 4) . self::mask_string(substr($value, 4));
350 }
351 }
352 return $config;
353 }
354
355 /**
356 * Mask String
357 * @since 1.2.13
358 * @return array|boolean|string|integer|float|double
359 */
360 public static function mask_string($string){
361 return str_repeat('*', strlen($string));
362 }
363
364 /**
365 * Function To get Current settings
366 * @since 1.0.0
367 * @return array|boolean|string|integer|float|double
368 */
369 public static function get_settings($option='', $default=false){
370 $current_setttings = self::get_option('settings',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
371 if(isset($current_setttings) && !empty($current_setttings)){
372 if(isset($option) && !empty($option)){
373 if(isset($current_setttings[$option])) {
374 return $current_setttings[$option];
375 } else {
376 return $default;
377 }
378 } else {
379 return $current_setttings;
380 }
381 } else {
382 return $default;
383 }
384 }
385
386 /**
387 * Function To get Current statuses
388 * @since 1.0.0
389 * @return array|boolean|string|integer|float|double
390 */
391 public static function get_status($option='', $default=false){
392 $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
393
394 if(isset($current_setttings) && !empty($current_setttings)){
395 if(isset($option) && !empty($option)){
396 if(isset($current_setttings[$option])) {
397 return $current_setttings[$option];
398 } else {
399 return $default;
400 }
401 } else {
402 return $current_setttings;
403 }
404 } else {
405 return $default;
406 }
407 }
408
409 /**
410 * Function To set statuses
411 * @since 1.0.0
412 *
413 */
414 public static function set_status($option='', $data=[]){
415 if(!isset($option) || empty($option)){
416 return false;
417 }
418
419 $meta_name = Schema::getConstant('STATUS_KEY');
420 $current_setttings = self::get_status('', []);
421
422 if(!is_array($current_setttings)) {
423 $current_setttings = [];
424 }
425
426 $current_setttings[$option] = $data;
427
428 return self::update_option('status', $current_setttings, $meta_name);
429 }
430
431 /**
432 * Function To get Current Service
433 * @since 1.0.0
434 * @return array|boolean|string|integer|float|double
435 */
436 public static function get_service(){
437 $current_service = self::get_credentials('service', '');
438 if(isset($current_service) && !empty($current_service)){
439 return $current_service;
440 } else {
441 return false;
442 }
443 }
444
445 /**
446 * Function To get Current config
447 * @since 1.0.0
448 * @return array|boolean|string|integer|float|double
449 */
450 public static function get_config($option='', $default=false){
451 $current_setttings = self::get_credentials('config',[]);
452 if(isset($current_setttings) && !empty($current_setttings)){
453 if(isset($option) && !empty($option)){
454 if(isset($current_setttings[$option])) {
455 return $current_setttings[$option];
456 } else {
457 return $default;
458 }
459 } else {
460 return $current_setttings;
461 }
462 } else {
463 return $default;
464 }
465 }
466
467 /**
468 * Function to check serving media environment is ok
469 * @since 1.0.0
470 * @return boolean
471 */
472 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
473 return (
474 self::is_service_enabled() &&
475 self::get_settings('rewrite_url') &&
476 ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
477 );
478 }
479
480 /**
481 * Whether a specific attachment's URL should resolve to the cloud copy — same as
482 * is_ok_to_serve() plus a per-item override point (e.g. Pro's "Use Server URL").
483 * Only for genuine URL-building call sites; is_ok_to_serve() is also reused elsewhere
484 * as a plain "is this item managed" check and must keep its original meaning.
485 * @since 1.4.1
486 */
487 public static function should_serve_from_cloud($attachment_id, $source_type = 'media_library') {
488 if (!self::is_ok_to_serve($attachment_id)) {
489 return false;
490 }
491 return (bool) apply_filters('wpmcs_should_serve_from_cloud', true, $attachment_id, $source_type);
492 }
493
494 /**
495 * Function to check uploading media environment is ok
496 * @since 1.0.0
497 * @return boolean
498 */
499 public static function is_ok_to_upload($attachment_id = false){
500 return (
501 self::is_service_enabled() &&
502 self::get_settings('copy_to_bucket') &&
503 isset($attachment_id) && !empty($attachment_id)
504 );
505 }
506
507 /**
508 * Whether stored credentials are complete for the configured service.
509 * @since 1.3.11
510 * @return boolean
511 */
512 private static function has_valid_storage_credentials() {
513 return self::get_service_configuration_error() === '';
514 }
515
516 /**
517 * Human-readable error when storage credentials are incomplete.
518 * @since 1.3.11
519 * @return string Empty when valid.
520 */
521 public static function get_service_configuration_error() {
522 $service = self::get_service();
523 if(!$service) {
524 return '';
525 }
526
527 $credentials = self::get_credentials('', [], false);
528 $bucketConfig = isset($credentials['bucketConfig']) ? $credentials['bucketConfig'] : [];
529
530 if(empty($bucketConfig['bucket_name'])) {
531 return esc_html__('Bucket name is not configured.', 'media-cloud-sync');
532 }
533
534 $configSource = self::get_credentials_source();
535
536 if($configSource === 'config') {
537 if(!self::is_wp_config_credentials_defined()) {
538 return esc_html__('WPMCS_CONFIG is not defined in wp-config.php', 'media-cloud-sync');
539 }
540
541 $config = self::get_wp_config_credentials();
542 $missing = [];
543 foreach(Service::get_required_config_keys($service) as $key) {
544 if(!isset($config[$key]) || $config[$key] === '') {
545 $missing[] = $key;
546 }
547 }
548 if(!empty($missing)) {
549 /* translators: %s: comma separated list of missing configuration keys */
550 return sprintf(esc_html__('WPMCS_CONFIG is missing key(s): %s', 'media-cloud-sync'), implode(', ', $missing));
551 }
552 } else {
553 $config = isset($credentials['config']) ? $credentials['config'] : [];
554 $missing = [];
555 foreach(Service::get_required_config_keys($service) as $key) {
556 if(!isset($config[$key]) || $config[$key] === '') {
557 $missing[] = $key;
558 }
559 }
560 if(!empty($missing)) {
561 /* translators: %s: comma separated list of missing configuration keys */
562 return sprintf(esc_html__('Storage credentials are incomplete. Missing key(s): %s', 'media-cloud-sync'), implode(', ', $missing));
563 }
564 }
565
566 return '';
567 }
568
569 /**
570 * Function To check service is enabled
571 * @since 1.0.0
572 * @return array|boolean|string|integer|float|double
573 */
574 public static function is_service_enabled(){
575 return !!self::get_service() && self::has_valid_storage_credentials();
576 }
577
578 /**
579 * Check whether a file exist in a list of files
580 * @since 1.0.1
581 * @return boolean
582 */
583 public static function check_existing_file_names( $filename, $files ) {
584 $fname = pathinfo( $filename, PATHINFO_FILENAME );
585 $ext = pathinfo( $filename, PATHINFO_EXTENSION );
586
587 // Edge case, file names like `.ext`.
588 if ( empty( $fname ) ) {
589 return false;
590 }
591
592 if ( $ext ) {
593 $ext = ".$ext";
594 }
595
596 $regex = '/^' . preg_quote( $fname ) . '-(?:\d+x\d+|scaled|rotated)' . preg_quote( $ext ) . '$/i';
597
598 foreach ( $files as $file ) {
599 if (
600 preg_match( $regex, wp_basename($file) ) ||
601 $filename == $file
602 ) {
603 return true;
604 }
605 }
606
607 return false;
608 }
609
610
611 /**
612 * Get relative attachment path for local source or remote object key.
613 *
614 * @param string $file File path, URL, or object key
615 * @param string $type 'source' (local WP) or 'key' (cloud / CDN)
616 *
617 * @return string|false
618 */
619 public static function get_attachment_source_path( $file, $type = 'source' ) {
620 if ( empty( $file ) || ! is_string( $file ) ) {
621 return false;
622 }
623
624 // Normalize slashes early
625 $file = str_replace( '\\', '/', $file );
626
627 // filter_var(..., FILTER_VALIDATE_URL) requires a scheme, but callers like
628 // FilterContent::get_item_sources_from_urls() intentionally pass scheme-relative
629 // URLs (Utils::remove_scheme()/reduce_url() strip it) — wp_parse_url() handles
630 // "//host/path" correctly, so treat that as URL-like too.
631 $is_url = filter_var( $file, FILTER_VALIDATE_URL ) || 0 === strpos( $file, '//' );
632
633 /**
634 * -------------------------------------------------
635 * TYPE: SOURCE (WordPress local paths / URLs)
636 * -------------------------------------------------
637 */
638 if ( $type === 'source' ) {
639
640 $uploads = wp_get_upload_dir();
641 if ( empty( $uploads ) || ! empty( $uploads['error'] ) ) {
642 return false;
643 }
644
645 $basedir = str_replace( '\\', '/', $uploads['basedir'] );
646 $baseurl = str_replace( '\\', '/', $uploads['baseurl'] );
647
648 // If URL → extract path, then strip using baseurl's own path component —
649 // once scheme+host are gone, comparing against the full $baseurl string
650 // (which still has them) never matches.
651 if ( $is_url ) {
652 $parsed = wp_parse_url( $file );
653 $file = $parsed['path'] ?? '';
654
655 $baseurl_path = (string) wp_parse_url( $baseurl, PHP_URL_PATH );
656 if ( $baseurl_path !== '' && 0 === strpos( $file, $baseurl_path ) ) {
657 $file = substr( $file, strlen( $baseurl_path ) );
658 }
659 } else {
660 // Strip WordPress upload root
661 if ( 0 === strpos( $file, $basedir ) ) {
662 $file = substr( $file, strlen( $basedir ) );
663 } elseif ( 0 === strpos( $file, $baseurl ) ) {
664 $file = substr( $file, strlen( $baseurl ) );
665 }
666 }
667 }
668
669 /**
670 * -------------------------------------------------
671 * TYPE: KEY (Cloud / CDN paths or URLs)
672 * -------------------------------------------------
673 */
674 elseif ( $type === 'key' ) {
675
676 // URL → extract path only
677 if ( $is_url ) {
678 $parsed = wp_parse_url( $file );
679 $file = $parsed['path'] ?? '';
680 }
681
682 $file = ltrim( $file, '/' );
683
684 $enable_base_path = self::get_settings( 'enable_base_path', true );
685 $base_path = trim( (string) self::get_settings( 'base_path', '' ), '/' );
686
687 /**
688 * If base_path is enabled and exists as a FULL segment,
689 * strip everything before it.
690 */
691 if ( $enable_base_path && $base_path !== '' ) {
692 $pattern = '#(^|/)' . preg_quote( $base_path, '#' ) . '(/|$)#';
693
694 if ( preg_match( $pattern, $file, $m, PREG_OFFSET_CAPTURE ) ) {
695 $file = substr( $file, $m[0][1] );
696 }
697 }
698 }
699
700 // Final cleanup
701 $file = trim( $file, '/' );
702
703 /**
704 * Reject directory-only paths
705 */
706 if ( $file === '' || substr( $file, -1 ) === '/' ) {
707 return false;
708 }
709
710 // Reject a literal ".." path segment — callers resolve this against the uploads
711 // basedir and pass it straight to file_exists()/upload, so an untrimmed "../../wp-config.php"
712 // would otherwise let a crafted source URL read/upload a file outside the uploads directory.
713 if ( in_array( '..', explode( '/', $file ), true ) ) {
714 return false;
715 }
716
717 return apply_filters(
718 'wpmcs_get_relative_file_path_from_upload_directory',
719 $file,
720 $type
721 );
722 }
723
724 /**
725 * Resolve a relative path (from get_attachment_source_path()) to an absolute path,
726 * only if it genuinely stays within the uploads basedir — a defense-in-depth check
727 * for callers about to file_exists()/read the result, alongside get_attachment_source_path()'s
728 * own "..".
729 * @since 1.4.1
730 * @return string|false
731 */
732 public static function resolve_within_uploads( $relative_path ) {
733 if ( empty( $relative_path ) || ! is_string( $relative_path ) ) {
734 return false;
735 }
736
737 $basedir = trailingslashit( wp_get_upload_dir()['basedir'] );
738 $absolute_path = $basedir . ltrim( $relative_path, '/' );
739
740 $real_basedir = realpath( $basedir );
741 $real_path = realpath( $absolute_path );
742
743 if ( $real_basedir === false || $real_path === false || 0 !== strpos( $real_path, $real_basedir ) ) {
744 return false;
745 }
746
747 return $absolute_path;
748 }
749
750
751 /**
752 * Whether the file may be synced based on plugin extension settings only.
753 *
754 * Uses `extensions_exclude` to block listed extensions and optional `extensions_include` as an allow-list.
755 * When `extensions_include` is empty, no extension is blocked by the allow-list (only exclude applies).
756 * WordPress MIME / `wp_check_filetype` is not used here.
757 *
758 * @since 1.0.0
759 * @param string $path Absolute or relative file path.
760 * @return bool
761 */
762 public static function is_extension_available( $path ) {
763 $settings = self::get_settings();
764 $path_parts = pathinfo( $path );
765
766 if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
767 return false;
768 }
769
770 $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
771
772 $allowed = [];
773 $not_allowed = [];
774
775 // Settings UI for these two fields is Pro-only; the values shouldn't apply without a license.
776 if ( self::is_pro_licensed() ) {
777 if (
778 ! empty( $settings['extensions_include_enabled'] ) &&
779 isset( $settings['extensions_include'] ) && is_array( $settings['extensions_include'] )
780 ) {
781 $allowed = array_map( 'strtolower', array_filter( $settings['extensions_include'], 'strlen' ) );
782 }
783
784 if (
785 ! empty( $settings['extensions_exclude_enabled'] ) &&
786 isset( $settings['extensions_exclude'] ) && is_array( $settings['extensions_exclude'] )
787 ) {
788 $not_allowed = array_map( 'strtolower', array_filter( $settings['extensions_exclude'], 'strlen' ) );
789 }
790 }
791
792 if ( in_array( $ext, $not_allowed, true ) ) {
793 return false;
794 }
795
796 if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
797 return false;
798 }
799
800 return true;
801 }
802
803 /**
804 * Generate prefix for object versioning
805 * @since 1.0.0
806 * @return string
807 */
808 public static function generate_object_versioning_prefix(){
809 $year_month = self::get_settings('year_month');
810 $date_format = $year_month ? 'dHis' : 'YmdHis';
811
812 // Use current time so that object version is unique
813 $time = current_time('timestamp');
814
815 $object_version = date($date_format, $time) . '/';
816 $object_version = apply_filters('wpmcs_object_version_prefix', $object_version);
817
818 return $object_version;
819 }
820
821
822 /**
823 * Object key used for bucket permission checks.
824 * Uses a .txt extension so CDN edge rules can serve the probe object.
825 * @since 1.3.12
826 * @return string
827 */
828 public static function get_permission_check_object_key() {
829 return self::generate_object_key(WPMCS_TOKEN . '_dummy-object-for-bucket-permission-check.txt', '');
830 }
831
832 /**
833 * Generate Key for Objects
834 * @since 1.0.0
835 */
836 public static function generate_object_key($relative_source_path, $prefix, $is_private = false) {
837 $upload_path = '';
838 $enable_base_path = self::get_settings('enable_base_path', true);
839 $base_path = self::get_settings('base_path', 'wp-content/uploads');
840 $year_month = self::get_settings('year_month', true);
841 $relative_source_path = ltrim( $relative_source_path, '/' );
842 $file_name = wp_basename( $relative_source_path );
843
844 if($is_private) {
845 // Private media is a Pro feature — Pro hooks this filter to supply the
846 // actual base_path+private_path root (see ProItem/ProPrivateMedia). An
847 // item can carry is_private=1 from when Pro *was* active and later have
848 // this filter go unanswered — Pro deactivated/uninstalled, or its license
849 // simply lapsing (ProPrivateMedia::register_hooks() itself requires an
850 // active license) — so this is a real, reachable state, not a hypothetical.
851 // Falling back to an empty root would silently place the file outside
852 // whatever path the bucket policy actually carves out — publicly
853 // readable, while is_private stays 1 and Item::get_url() keeps serving it
854 // as if it were still protected. Refuse instead: no key at all is safer
855 // than a wrong one for a file that's supposed to stay private.
856 if ( ! has_filter( 'wpmcs_private_object_key_root' ) ) {
857 return false;
858 }
859 $upload_path = apply_filters( 'wpmcs_private_object_key_root', '', $relative_source_path, $prefix );
860 } else {
861 if(!$enable_base_path) { // If base path is not enabled
862 $base_path = '';
863 }
864
865 if(isset($base_path) && !empty($base_path)) {
866 $upload_path.= preg_replace('~/+~', '/',
867 str_replace('\\', '/',
868 trim($base_path," \n\r\t\v\x00\/ ")
869 )
870 );
871 }
872 }
873
874 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
875
876 if($keep_original_folder_structure) {
877 $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
878 } else {
879 if(isset($year_month) && $year_month) {
880 $year_month_prefix = self::get_year_month_from_file_path($relative_source_path);
881 if($year_month_prefix) {
882 $upload_path.= '/'.$year_month_prefix;
883 } else {
884 $upload_path.= '/'.date("Y/m");
885 }
886 }
887
888 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
889 }
890
891 return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
892 }
893
894
895 /**
896 * Check if a file path or URL follows the year/month structure and return the year/month as a string.
897 *
898 * @param string $path_or_url The relative path, absolute path, or URL to check.
899 * @return string|false The year/month string if valid, false otherwise.
900 */
901 public static function get_year_month_from_file_path( $path_or_url ) {
902 // Regex pattern to match paths and URLs containing 'YYYY/MM/' at any depth, allowing subdirectories afterward
903 $pattern = '#(?:^|/)(\d{4})/(0[1-9]|1[0-2])/[^/]+(?:/[^/]+)*$#';
904
905 // Check if the input matches the pattern
906 if ( preg_match( $pattern, $path_or_url, $matches ) ) {
907 // Return the year/month as a string in the format 'YYYY/MM'
908 return $matches[1] . '/' . $matches[2];
909 }
910
911 return false; // Invalid format
912 }
913
914
915 /**
916 * Maybe convert size to string
917 *
918 * @param int $attachment_id
919 * @param mixed $size
920 *
921 * @return null|string
922 */
923 public static function maybe_convert_size_to_string( $attachment_id, $size ) {
924 if ( is_array( $size ) ) {
925 $width = ( isset( $size[0] ) && $size[0] > 0 ) ? $size[0] : 1;
926 $height = ( isset( $size[1] ) && $size[1] > 0 ) ? $size[1] : 1;
927 $original_aspect_ratio = $width / $height;
928 $meta = wp_get_attachment_metadata( $attachment_id );
929
930 if ( ! isset( $meta['sizes'] ) || empty( $meta['sizes'] ) ) {
931 return false;
932 }
933
934 $sizes = $meta['sizes'];
935 uasort( $sizes, function ( $a, $b ) {
936 // Order by image area
937 return ( $a['width'] * $a['height'] ) - ( $b['width'] * $b['height'] );
938 } );
939
940 $near_matches = array();
941
942 foreach ( $sizes as $size => $value ) {
943 if ( $width > $value['width'] || $height > $value['height'] ) {
944 continue;
945 }
946 $aspect_ratio = $value['width'] / $value['height'];
947 if ( $aspect_ratio === $original_aspect_ratio ) {
948 return $size;
949 }
950 $near_matches[] = $size;
951 }
952 // Return nearest match
953 if ( ! empty( $near_matches ) ) {
954 return $near_matches[0];
955 }
956 }
957
958 return $size;
959 }
960
961 /**
962 * Reduce the given URL down to the simplest version of itself.
963 *
964 * Useful for matching against the full version of the URL in a full-text search
965 * or saving as a key for dictionary type lookup.
966 *
967 * @param string $url
968 *
969 * @return string
970 */
971 public static function reduce_url( $url ) {
972 $parts = static::parse_url( $url );
973 $host = isset( $parts['host'] ) ? $parts['host'] : '';
974 $port = isset( $parts['port'] ) ? ":{$parts['port']}" : '';
975 $path = isset( $parts['path'] ) ? $parts['path'] : '';
976
977 return '//' . $host . $port . $path;
978 }
979
980 /**
981 * Remove scheme from URL.
982 *
983 * @param string $url
984 * @return string
985 */
986 public static function remove_scheme( $url ) {
987 return preg_replace( '/^(?:http|https):/', '', $url );
988 }
989
990 /**
991 * Remove size from filename (image[-100x100].jpeg).
992 *
993 * @param string $url
994 * @param bool $remove_extension
995 *
996 * @return string
997 */
998 public static function remove_size_from_filename( $url, $remove_extension = false ) {
999 $url = preg_replace( '/^(\S+)-[0-9]{1,4}x[0-9]{1,4}(\.[a-zA-Z0-9\.]{2,})?/', '$1$2', $url );
1000
1001 $url = apply_filters( 'wpmcs_remove_size_from_filename', $url );
1002
1003 if ( $remove_extension ) {
1004 $ext = pathinfo( $url, PATHINFO_EXTENSION );
1005 $url = str_replace( ".$ext", '', $url );
1006 }
1007
1008 return $url;
1009 }
1010
1011 /**
1012 * Is the string a URL?
1013 *
1014 * @param mixed $string
1015 *
1016 * @return bool
1017 */
1018 public static function is_url( $string ): bool {
1019 if ( empty( $string ) || ! is_string( $string ) ) {
1020 return false;
1021 }
1022
1023 if ( preg_match( '@^(?:https?:)?//[a-zA-Z0-9\-]+@', $string ) ) {
1024 return true;
1025 }
1026
1027 return false;
1028 }
1029
1030 /**
1031 * Parses a URL into its components. Compatible with PHP < 5.4.7.
1032 *
1033 * @param string $url The URL to parse.
1034 *
1035 * @param int $component PHP_URL_ constant for URL component to return.
1036 *
1037 * @return mixed An array of the parsed components, mixed for a requested component, or false on error.
1038 */
1039 public static function parse_url( $url, $component = -1 ) {
1040 $url = trim( $url );
1041 $no_scheme = 0 === strpos( $url, '//' );
1042
1043 if ( $no_scheme ) {
1044 $url = 'http:' . $url;
1045 }
1046
1047 $parts = parse_url( $url, $component );
1048
1049 if ( 0 < $component ) {
1050 return $parts;
1051 }
1052
1053 if ( $no_scheme && is_array( $parts ) ) {
1054 unset( $parts['scheme'] );
1055 }
1056
1057 return $parts;
1058 }
1059
1060 /**
1061 * Is the given string a usable URL?
1062 *
1063 * We need URLs that include at least a domain and filename with extension
1064 * for URL rewriting in either direction.
1065 *
1066 * @param mixed $url
1067 *
1068 * @return bool
1069 */
1070 public static function is_file_url( $url ): bool {
1071 if ( ! static::is_url( $url ) ) {
1072 return false;
1073 }
1074
1075 $parts = static::parse_url( $url );
1076
1077 if (
1078 empty( $parts['host'] ) ||
1079 empty( $parts['path'] ) ||
1080 ! pathinfo( $parts['path'], PATHINFO_EXTENSION )
1081 ) {
1082 return false;
1083 }
1084
1085 return true;
1086 }
1087
1088
1089 /**
1090 * Remove query strings of services.
1091 *
1092 * @param string $content
1093 * @param string $base_url Optional base URL that must exist within URL for Amazon query strings to be removed.
1094 *
1095 * @return string
1096 */
1097 public static function remove_query_strings( $content, $base_url = '' ) {
1098 $pattern = '\?[^\s"<\?]*(?:X-Amz-Algorithm|AWSAccessKeyId|Key-Pair-Id|GoogleAccessId)=[^\s"<\?]+';
1099 $group = 0;
1100
1101 if ( ! is_string( $content ) ) {
1102 return $content;
1103 }
1104
1105 if ( ! empty( $base_url ) ) {
1106 $pattern = preg_quote( $base_url, '/' ) . '[^\s"<\?]+(' . $pattern . ')';
1107 $group = 1;
1108 }
1109 if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches ) || ! isset( $matches[ $group ] ) ) {
1110 // No query strings found, return
1111 return $content;
1112 }
1113
1114 $matches = array_unique( $matches[ $group ] );
1115
1116 foreach ( $matches as $match ) {
1117 $content = str_replace( $match, '', $content );
1118 }
1119 return $content;
1120 }
1121
1122 /**
1123 * Maybe unserialize data, but not if an object.
1124 *
1125 * @param mixed $data
1126 *
1127 * @return mixed
1128 */
1129 public static function maybe_unserialize( $data ) {
1130 if ( is_serialized( $data ) ) {
1131 return @unserialize( $data, array( 'allowed_classes' => false ) ); // @phpcs:ignore
1132 }
1133
1134 return $data;
1135 }
1136
1137
1138 /**
1139 * Serialize data if needed.
1140 *
1141 * @param mixed $data
1142 * @return mixed
1143 */
1144 public static function maybe_serialize( $data ) {
1145 if ( is_array( $data ) || is_object( $data ) ) {
1146 return serialize( $data );
1147 }
1148
1149 // If it's not an array or object, don't serialize. If it is already serialized, return as is.
1150 if ( is_serialized( $data ) ) {
1151 return $data;
1152 }
1153
1154 return $data;
1155 }
1156
1157
1158 /**
1159 * Validate JSON
1160 */
1161 public static function is_json( $string ) {
1162 json_decode( $string );
1163 return ( json_last_error() == JSON_ERROR_NONE );
1164 }
1165
1166 /**
1167 * Check whether a specific class::method exists in the current call stack.
1168 *
1169 * Useful for detecting callers like WooCommerce image regeneration
1170 * without hard dependencies.
1171 *
1172 * @since 1.3.7
1173 * @param string $class Fully qualified class name.
1174 * @param string|null $function Method name (optional).
1175 * @param int $depth Backtrace depth limit.
1176 *
1177 * @return bool
1178 */
1179 public static function is_called_from(
1180 string $class,
1181 ?string $function = null,
1182 int $depth = 15
1183 ) : bool {
1184
1185 $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, $depth );
1186
1187 foreach ( $trace as $frame ) {
1188
1189 if ( empty( $frame['class'] ) ) {
1190 continue;
1191 }
1192
1193 if ( $frame['class'] !== $class ) {
1194 continue;
1195 }
1196
1197 // If function not specified, class match is enough
1198 if ( $function === null ) {
1199 return true;
1200 }
1201
1202 if ( isset( $frame['function'] ) && $frame['function'] === $function ) {
1203 return true;
1204 }
1205 }
1206
1207 return false;
1208 }
1209
1210
1211 /**
1212 * Is this an AJAX process?
1213 *
1214 * @return bool
1215 */
1216 public static function is_ajax() {
1217 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
1218 return true;
1219 }
1220
1221 return false;
1222 }
1223
1224 /**
1225 * Helper function for filtering super globals. Easily testable.
1226 *
1227 * @param string $variable
1228 * @param int $type
1229 * @param int $filter
1230 * @param mixed $options
1231 *
1232 * @return mixed
1233 */
1234 public static function filter_input( $variable, $type = INPUT_GET, $filter = FILTER_DEFAULT, $options = array() ) {
1235 return filter_input( $type, $variable, $filter, $options );
1236 }
1237
1238 /**
1239 * Get license data safe for frontend exposure (no raw key).
1240 *
1241 * @return array
1242 */
1243 public static function get_safe_license_data() {
1244 $data = get_option('wpmcs_pro_license', []);
1245 if (empty($data)) {
1246 return [];
1247 }
1248 $key = $data['license_key'] ?? '';
1249 $masked = '';
1250 if (!empty($key)) {
1251 $parts = explode('-', $key);
1252 if (count($parts) <= 2) {
1253 $masked = str_repeat('*', strlen($key));
1254 } else {
1255 $first = $parts[0];
1256 $last = end($parts);
1257 $middle_count = count($parts) - 2;
1258 $masked_middle = array_fill(0, $middle_count, '****');
1259 $masked = $first . '-' . implode('-', $masked_middle) . '-' . $last;
1260 }
1261 }
1262 return [
1263 'masked_key' => $masked,
1264 'status' => $data['status'] ?? '',
1265 'expiry' => $data['expiry'] ?? '',
1266 'is_expired' => $data['is_expired'] ?? false,
1267 'is_domain_activated' => $data['is_domain_activated'] ?? false,
1268 'can_activate' => $data['can_activate'] ?? false,
1269 'message' => $data['message'] ?? '',
1270 'last_checked' => $data['last_checked'] ?? 0,
1271 ];
1272 }
1273
1274 /**
1275 * Whether Pro is installed and currently licensed (active, domain-activated, not expired).
1276 * Single source of truth for this check — must match the frontend's isLicenseValid()
1277 * (app/src/helper/index.js) field-for-field so backend and frontend never disagree about
1278 * whether ajax/mixed sync mode is actually usable.
1279 * @since 1.3.13
1280 * @return bool
1281 */
1282 public static function is_pro_licensed() {
1283 if (!defined('WPMCS_PRO_VERSION')) {
1284 return false;
1285 }
1286
1287 $license = self::get_safe_license_data();
1288
1289 return ($license['status'] ?? '') === 'active'
1290 && ($license['is_domain_activated'] ?? false) === true
1291 && empty($license['is_expired']);
1292 }
1293
1294 // Cache-Control for newly uploaded objects; 1 month by default, custom duration is Pro-only, no-cache only when duration is explicitly 0.
1295 // @since 1.4.0
1296 public static function get_cache_control_header() {
1297 $duration = 1;
1298 $unit = 'months';
1299
1300 if (self::is_pro_licensed() && self::get_settings('cache_control_enabled', false)) {
1301 $duration = (int) self::get_settings('cache_control_duration', 1);
1302 $unit = self::get_settings('cache_control_unit', 'months');
1303 }
1304
1305 if ($duration <= 0) {
1306 return 'no-cache, no-store, must-revalidate';
1307 }
1308
1309 $unit_seconds = [
1310 'seconds' => 1,
1311 'minutes' => MINUTE_IN_SECONDS,
1312 'hours' => HOUR_IN_SECONDS,
1313 'days' => DAY_IN_SECONDS,
1314 'weeks' => WEEK_IN_SECONDS,
1315 'months' => MONTH_IN_SECONDS,
1316 'years' => YEAR_IN_SECONDS,
1317 ];
1318
1319 return 'public, max-age=' . ($duration * ($unit_seconds[$unit] ?? MONTH_IN_SECONDS));
1320 }
1321
1322 }
1323