PluginProbe
Media Cloud Sync / trunk
Media Cloud Sync vtrunk
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 1.3.0 All 34 releases
media-cloud-sync / includes / config / utils.php

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

1,211 lines 37.7 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, true );
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, true );
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, true );
145 }
146
147
148 /**
149 * Clear meta from database
150 *
151 * @param string|false $meta_name
152 * @param string $meta_table
153 * @param bool $flush_cache Whether to flush the plugin object cache afterward.
154 */
155 public static function clear_all_meta($meta_name = false, $meta_table = 'all', $flush_cache = true) {
156 global $wpdb;
157
158 $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('META_KEY') : $meta_name;
159
160 if ( empty( $meta_name ) ) {
161 return false; // Avoid accidental deletions if the meta_key is empty
162 }
163
164 $meta_tables = $meta_table == 'all' ? ['postmeta', 'usermeta', 'options'] : [$meta_table];
165
166 if( in_array('postmeta', $meta_tables) ) {
167 // Clear post meta
168 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
169 }
170
171 if( in_array('usermeta', $meta_tables) ) {
172 // Clear user meta
173 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", $meta_name ) );
174 }
175
176 if( in_array('options', $meta_tables) ) {
177 // Clear options
178 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $meta_name ) );
179 }
180
181 self::invalidate_core_meta_cache($meta_tables, $meta_name);
182
183 if ($flush_cache) {
184 Cache::flush_object_cache();
185 }
186
187 return true;
188 }
189
190 /**
191 * Clears all content meta from the database
192 *
193 * @param string|false $meta_name Optional. The meta key to clear. Defaults to the constant CONTENT_META_KEY.
194 * @param bool $flush_cache Whether to flush the plugin object cache afterward.
195 */
196 public static function clear_all_content_meta($meta_name = false, $flush_cache = true) {
197 global $wpdb;
198 $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('CONTENT_META_KEY') : $meta_name;
199
200 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
201
202 self::invalidate_core_meta_cache(['postmeta'], $meta_name);
203
204 if ($flush_cache) {
205 Cache::flush_object_cache();
206 }
207
208 return true;
209 }
210
211 /**
212 * Invalidate WordPress core object caches after direct SQL meta deletes.
213 */
214 private static function invalidate_core_meta_cache($meta_tables, $meta_name) {
215 if (!function_exists('wp_cache_delete')) {
216 return;
217 }
218
219 if (in_array('options', $meta_tables, true)) {
220 wp_cache_delete('alloptions', 'options');
221 wp_cache_delete($meta_name, 'options');
222 }
223
224 if (in_array('postmeta', $meta_tables, true)) {
225 if (function_exists('wp_cache_set_last_changed')) {
226 wp_cache_set_last_changed('posts');
227 } elseif (function_exists('wp_cache_delete')) {
228 wp_cache_delete('last_changed', 'posts');
229 }
230 }
231 }
232
233
234
235 /**
236 * Function To get Current credentials
237 * @since 1.0.0
238 * @return array|boolean|string|integer|float|double
239 */
240 public static function get_credentials($option='', $default=false, $masked_config = false){
241 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
242 if(isset($current_setttings) && !empty($current_setttings)){
243 // Resolve the credential source. Defaults to 'database' for backward compatibility.
244 $source = isset($current_setttings['configSource']) ? $current_setttings['configSource'] : 'database';
245
246 if($source === 'config') {
247 // Credentials live in the WPMCS_CONFIG constant (wp-config.php).
248 // Server-side consumers receive the real values; REST-facing (masked) callers
249 // receive nothing so the constant contents are never exposed to the browser.
250 $current_setttings['config'] = $masked_config ? [] : self::get_wp_config_credentials();
251 } elseif($masked_config && isset($current_setttings['config'])) {
252 $current_setttings['config'] = self::mask_config($current_setttings['config']);
253 }
254
255 if(isset($option) && !empty($option)){
256 if(isset($current_setttings[$option])) {
257 return $current_setttings[$option];
258 } else {
259 return $default;
260 }
261 } else {
262 return $current_setttings;
263 }
264 } else {
265 return $default;
266 }
267 }
268
269 /**
270 * Check whether credentials are defined via a wp-config.php constant.
271 * @since 1.3.11
272 * @param string $constant
273 * @return boolean
274 */
275 public static function is_wp_config_credentials_defined($constant = 'WPMCS_CONFIG'){
276 return defined($constant);
277 }
278
279 /**
280 * Get credentials defined via a wp-config.php constant. Accepts a PHP array or serialized string.
281 * @since 1.3.11
282 * @param string $constant
283 * @return array
284 */
285 public static function get_wp_config_credentials($constant = 'WPMCS_CONFIG'){
286 if(!defined($constant)) {
287 return [];
288 }
289 $config = constant($constant);
290 if(is_string($config)) {
291 $config = self::maybe_unserialize($config);
292 }
293 return is_array($config) ? $config : [];
294 }
295
296 /**
297 * Get the current credential source ('database' | 'config').
298 * Defaults to 'database' for backward compatibility.
299 * @since 1.3.11
300 * @return string
301 */
302 public static function get_credentials_source(){
303 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
304 return isset($current_setttings['configSource']) ? $current_setttings['configSource'] : 'database';
305 }
306
307 /**
308 * Mask Config
309 * @since 1.2.13
310 * @return array|boolean|string|integer|float|double
311 */
312 public static function mask_config($config){
313 foreach ($config as $key => $value) {
314 if (in_array($key, ['config_json', 'secret_key'])) {
315 $config[$key] = substr($value, 0, 4) . self::mask_string(substr($value, 4));
316 }
317 }
318 return $config;
319 }
320
321 /**
322 * Mask String
323 * @since 1.2.13
324 * @return array|boolean|string|integer|float|double
325 */
326 public static function mask_string($string){
327 return str_repeat('*', strlen($string));
328 }
329
330 /**
331 * Function To get Current settings
332 * @since 1.0.0
333 * @return array|boolean|string|integer|float|double
334 */
335 public static function get_settings($option='', $default=false){
336 $current_setttings = self::get_option('settings',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
337 if(isset($current_setttings) && !empty($current_setttings)){
338 if(isset($option) && !empty($option)){
339 if(isset($current_setttings[$option])) {
340 return $current_setttings[$option];
341 } else {
342 return $default;
343 }
344 } else {
345 return $current_setttings;
346 }
347 } else {
348 return $default;
349 }
350 }
351
352 /**
353 * Function To get Current statuses
354 * @since 1.0.0
355 * @return array|boolean|string|integer|float|double
356 */
357 public static function get_status($option='', $default=false){
358 $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
359
360 if(isset($current_setttings) && !empty($current_setttings)){
361 if(isset($option) && !empty($option)){
362 if(isset($current_setttings[$option])) {
363 return $current_setttings[$option];
364 } else {
365 return $default;
366 }
367 } else {
368 return $current_setttings;
369 }
370 } else {
371 return $default;
372 }
373 }
374
375 /**
376 * Function To set statuses
377 * @since 1.0.0
378 *
379 */
380 public static function set_status($option='', $data=[]){
381 if(!isset($option) || empty($option)){
382 return false;
383 }
384
385 $meta_name = Schema::getConstant('STATUS_KEY');
386 $current_setttings = self::get_status('', []);
387
388 if(!is_array($current_setttings)) {
389 $current_setttings = [];
390 }
391
392 $current_setttings[$option] = $data;
393
394 return self::update_option('status', $current_setttings, $meta_name);
395 }
396
397 /**
398 * Function To get Current Service
399 * @since 1.0.0
400 * @return array|boolean|string|integer|float|double
401 */
402 public static function get_service(){
403 $current_service = self::get_credentials('service', '');
404 if(isset($current_service) && !empty($current_service)){
405 return $current_service;
406 } else {
407 return false;
408 }
409 }
410
411 /**
412 * Function To get Current config
413 * @since 1.0.0
414 * @return array|boolean|string|integer|float|double
415 */
416 public static function get_config($option='', $default=false){
417 $current_setttings = self::get_credentials('config',[]);
418 if(isset($current_setttings) && !empty($current_setttings)){
419 if(isset($option) && !empty($option)){
420 if(isset($current_setttings[$option])) {
421 return $current_setttings[$option];
422 } else {
423 return $default;
424 }
425 } else {
426 return $current_setttings;
427 }
428 } else {
429 return $default;
430 }
431 }
432
433 /**
434 * Function to check serving media environment is ok
435 * @since 1.0.0
436 * @return boolean
437 */
438 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
439 return (
440 self::is_service_enabled() &&
441 self::get_settings('rewrite_url') &&
442 ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
443 );
444 }
445
446 /**
447 * Function to check uploading media environment is ok
448 * @since 1.0.0
449 * @return boolean
450 */
451 public static function is_ok_to_upload($attachment_id = false){
452 return (
453 self::is_service_enabled() &&
454 self::get_settings('copy_to_bucket') &&
455 isset($attachment_id) && !empty($attachment_id)
456 );
457 }
458
459 /**
460 * Whether stored credentials are complete for the configured service.
461 * @since 1.3.11
462 * @return boolean
463 */
464 private static function has_valid_storage_credentials() {
465 return self::get_service_configuration_error() === '';
466 }
467
468 /**
469 * Human-readable error when storage credentials are incomplete.
470 * @since 1.3.11
471 * @return string Empty when valid.
472 */
473 public static function get_service_configuration_error() {
474 $service = self::get_service();
475 if(!$service) {
476 return '';
477 }
478
479 $credentials = self::get_credentials('', [], false);
480 $bucketConfig = isset($credentials['bucketConfig']) ? $credentials['bucketConfig'] : [];
481
482 if(empty($bucketConfig['bucket_name'])) {
483 return esc_html__('Bucket name is not configured.', 'media-cloud-sync');
484 }
485
486 $configSource = self::get_credentials_source();
487
488 if($configSource === 'config') {
489 if(!self::is_wp_config_credentials_defined()) {
490 return esc_html__('WPMCS_CONFIG is not defined in wp-config.php', 'media-cloud-sync');
491 }
492
493 $config = self::get_wp_config_credentials();
494 $missing = [];
495 foreach(Service::get_required_config_keys($service) as $key) {
496 if(!isset($config[$key]) || $config[$key] === '') {
497 $missing[] = $key;
498 }
499 }
500 if(!empty($missing)) {
501 /* translators: %s: comma separated list of missing configuration keys */
502 return sprintf(esc_html__('WPMCS_CONFIG is missing key(s): %s', 'media-cloud-sync'), implode(', ', $missing));
503 }
504 } else {
505 $config = isset($credentials['config']) ? $credentials['config'] : [];
506 $missing = [];
507 foreach(Service::get_required_config_keys($service) as $key) {
508 if(!isset($config[$key]) || $config[$key] === '') {
509 $missing[] = $key;
510 }
511 }
512 if(!empty($missing)) {
513 /* translators: %s: comma separated list of missing configuration keys */
514 return sprintf(esc_html__('Storage credentials are incomplete. Missing key(s): %s', 'media-cloud-sync'), implode(', ', $missing));
515 }
516 }
517
518 return '';
519 }
520
521 /**
522 * Function To check service is enabled
523 * @since 1.0.0
524 * @return array|boolean|string|integer|float|double
525 */
526 public static function is_service_enabled(){
527 return !!self::get_service() && self::has_valid_storage_credentials();
528 }
529
530 /**
531 * Check whether a file exist in a list of files
532 * @since 1.0.1
533 * @return boolean
534 */
535 public static function check_existing_file_names( $filename, $files ) {
536 $fname = pathinfo( $filename, PATHINFO_FILENAME );
537 $ext = pathinfo( $filename, PATHINFO_EXTENSION );
538
539 // Edge case, file names like `.ext`.
540 if ( empty( $fname ) ) {
541 return false;
542 }
543
544 if ( $ext ) {
545 $ext = ".$ext";
546 }
547
548 $regex = '/^' . preg_quote( $fname ) . '-(?:\d+x\d+|scaled|rotated)' . preg_quote( $ext ) . '$/i';
549
550 foreach ( $files as $file ) {
551 if (
552 preg_match( $regex, wp_basename($file) ) ||
553 $filename == $file
554 ) {
555 return true;
556 }
557 }
558
559 return false;
560 }
561
562
563 /**
564 * Get relative attachment path for local source or remote object key.
565 *
566 * @param string $file File path, URL, or object key
567 * @param string $type 'source' (local WP) or 'key' (cloud / CDN)
568 *
569 * @return string|false
570 */
571 public static function get_attachment_source_path( $file, $type = 'source' ) {
572 if ( empty( $file ) || ! is_string( $file ) ) {
573 return false;
574 }
575
576 // Normalize slashes early
577 $file = str_replace( '\\', '/', $file );
578
579 /**
580 * -------------------------------------------------
581 * TYPE: SOURCE (WordPress local paths / URLs)
582 * -------------------------------------------------
583 */
584 if ( $type === 'source' ) {
585
586 $uploads = wp_get_upload_dir();
587 if ( empty( $uploads ) || ! empty( $uploads['error'] ) ) {
588 return false;
589 }
590
591 $basedir = str_replace( '\\', '/', $uploads['basedir'] );
592 $baseurl = str_replace( '\\', '/', $uploads['baseurl'] );
593
594 // If URL → extract path
595 if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
596 $parsed = wp_parse_url( $file );
597 $file = $parsed['path'] ?? '';
598 }
599
600 // Strip WordPress upload root
601 if ( 0 === strpos( $file, $basedir ) ) {
602 $file = substr( $file, strlen( $basedir ) );
603 } elseif ( 0 === strpos( $file, $baseurl ) ) {
604 $file = substr( $file, strlen( $baseurl ) );
605 }
606 }
607
608 /**
609 * -------------------------------------------------
610 * TYPE: KEY (Cloud / CDN paths or URLs)
611 * -------------------------------------------------
612 */
613 elseif ( $type === 'key' ) {
614
615 // URL → extract path only
616 if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
617 $parsed = wp_parse_url( $file );
618 $file = $parsed['path'] ?? '';
619 }
620
621 $file = ltrim( $file, '/' );
622
623 $enable_base_path = self::get_settings( 'enable_base_path', true );
624 $base_path = trim( (string) self::get_settings( 'base_path', '' ), '/' );
625
626 /**
627 * If base_path is enabled and exists as a FULL segment,
628 * strip everything before it.
629 */
630 if ( $enable_base_path && $base_path !== '' ) {
631 $pattern = '#(^|/)' . preg_quote( $base_path, '#' ) . '(/|$)#';
632
633 if ( preg_match( $pattern, $file, $m, PREG_OFFSET_CAPTURE ) ) {
634 $file = substr( $file, $m[0][1] );
635 }
636 }
637 }
638
639 // Final cleanup
640 $file = trim( $file, '/' );
641
642 /**
643 * Reject directory-only paths
644 */
645 if ( $file === '' || substr( $file, -1 ) === '/' ) {
646 return false;
647 }
648
649 return apply_filters(
650 'wpmcs_get_relative_file_path_from_upload_directory',
651 $file,
652 $type
653 );
654 }
655
656
657 /**
658 * Whether the file may be synced based on plugin extension settings only.
659 *
660 * Uses `extensions_exclude` to block listed extensions and optional `extensions_include` as an allow-list.
661 * When `extensions_include` is empty, no extension is blocked by the allow-list (only exclude applies).
662 * WordPress MIME / `wp_check_filetype` is not used here.
663 *
664 * @since 1.0.0
665 * @param string $path Absolute or relative file path.
666 * @return bool
667 */
668 public static function is_extension_available( $path ) {
669 $settings = self::get_settings();
670 $path_parts = pathinfo( $path );
671
672 if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
673 return false;
674 }
675
676 $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
677
678 $allowed = [];
679 $not_allowed = [];
680
681 // Settings UI for these two fields is Pro-only; the values shouldn't apply without a license.
682 if ( self::is_pro_licensed() ) {
683 if (
684 ! empty( $settings['extensions_include_enabled'] ) &&
685 isset( $settings['extensions_include'] ) && is_array( $settings['extensions_include'] )
686 ) {
687 $allowed = array_map( 'strtolower', array_filter( $settings['extensions_include'], 'strlen' ) );
688 }
689
690 if (
691 ! empty( $settings['extensions_exclude_enabled'] ) &&
692 isset( $settings['extensions_exclude'] ) && is_array( $settings['extensions_exclude'] )
693 ) {
694 $not_allowed = array_map( 'strtolower', array_filter( $settings['extensions_exclude'], 'strlen' ) );
695 }
696 }
697
698 if ( in_array( $ext, $not_allowed, true ) ) {
699 return false;
700 }
701
702 if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
703 return false;
704 }
705
706 return true;
707 }
708
709 /**
710 * Generate prefix for object versioning
711 * @since 1.0.0
712 * @return string
713 */
714 public static function generate_object_versioning_prefix(){
715 $year_month = self::get_settings('year_month');
716 $date_format = $year_month ? 'dHis' : 'YmdHis';
717
718 // Use current time so that object version is unique
719 $time = current_time('timestamp');
720
721 $object_version = date($date_format, $time) . '/';
722 $object_version = apply_filters('wpmcs_object_version_prefix', $object_version);
723
724 return $object_version;
725 }
726
727
728 /**
729 * Object key used for bucket permission checks.
730 * Uses a .txt extension so CDN edge rules can serve the probe object.
731 * @since 1.3.12
732 * @return string
733 */
734 public static function get_permission_check_object_key() {
735 return self::generate_object_key(WPMCS_TOKEN . '_dummy-object-for-bucket-permission-check.txt', '');
736 }
737
738 /**
739 * Generate Key for Objects
740 * @since 1.0.0
741 */
742 public static function generate_object_key($relative_source_path, $prefix) {
743 $upload_path = '';
744 $enable_base_path = self::get_settings('enable_base_path', true);
745 $base_path = self::get_settings('base_path', 'wp-content/uploads');
746 $year_month = self::get_settings('year_month', true);
747 $relative_source_path = ltrim( $relative_source_path, '/' );
748 $file_name = wp_basename( $relative_source_path );
749
750 if(!$enable_base_path) { // If base path is not enabled
751 $base_path = '';
752 }
753
754 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
755
756 if(isset($base_path) && !empty($base_path)) {
757 $upload_path.= preg_replace('~/+~', '/',
758 str_replace('\\', '/',
759 trim($base_path," \n\r\t\v\x00\/ ")
760 )
761 );
762 }
763
764 if($keep_original_folder_structure) {
765 $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
766 } else {
767 if(isset($year_month) && $year_month) {
768 $year_month_prefix = self::get_year_month_from_file_path($relative_source_path);
769 if($year_month_prefix) {
770 $upload_path.= '/'.$year_month_prefix;
771 } else {
772 $upload_path.= '/'.date("Y/m");
773 }
774 }
775
776 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
777 }
778
779 return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
780 }
781
782
783 /**
784 * Check if a file path or URL follows the year/month structure and return the year/month as a string.
785 *
786 * @param string $path_or_url The relative path, absolute path, or URL to check.
787 * @return string|false The year/month string if valid, false otherwise.
788 */
789 public static function get_year_month_from_file_path( $path_or_url ) {
790 // Regex pattern to match paths and URLs containing 'YYYY/MM/' at any depth, allowing subdirectories afterward
791 $pattern = '#(?:^|/)(\d{4})/(0[1-9]|1[0-2])/[^/]+(?:/[^/]+)*$#';
792
793 // Check if the input matches the pattern
794 if ( preg_match( $pattern, $path_or_url, $matches ) ) {
795 // Return the year/month as a string in the format 'YYYY/MM'
796 return $matches[1] . '/' . $matches[2];
797 }
798
799 return false; // Invalid format
800 }
801
802
803 /**
804 * Maybe convert size to string
805 *
806 * @param int $attachment_id
807 * @param mixed $size
808 *
809 * @return null|string
810 */
811 public static function maybe_convert_size_to_string( $attachment_id, $size ) {
812 if ( is_array( $size ) ) {
813 $width = ( isset( $size[0] ) && $size[0] > 0 ) ? $size[0] : 1;
814 $height = ( isset( $size[1] ) && $size[1] > 0 ) ? $size[1] : 1;
815 $original_aspect_ratio = $width / $height;
816 $meta = wp_get_attachment_metadata( $attachment_id );
817
818 if ( ! isset( $meta['sizes'] ) || empty( $meta['sizes'] ) ) {
819 return false;
820 }
821
822 $sizes = $meta['sizes'];
823 uasort( $sizes, function ( $a, $b ) {
824 // Order by image area
825 return ( $a['width'] * $a['height'] ) - ( $b['width'] * $b['height'] );
826 } );
827
828 $near_matches = array();
829
830 foreach ( $sizes as $size => $value ) {
831 if ( $width > $value['width'] || $height > $value['height'] ) {
832 continue;
833 }
834 $aspect_ratio = $value['width'] / $value['height'];
835 if ( $aspect_ratio === $original_aspect_ratio ) {
836 return $size;
837 }
838 $near_matches[] = $size;
839 }
840 // Return nearest match
841 if ( ! empty( $near_matches ) ) {
842 return $near_matches[0];
843 }
844 }
845
846 return $size;
847 }
848
849 /**
850 * Reduce the given URL down to the simplest version of itself.
851 *
852 * Useful for matching against the full version of the URL in a full-text search
853 * or saving as a key for dictionary type lookup.
854 *
855 * @param string $url
856 *
857 * @return string
858 */
859 public static function reduce_url( $url ) {
860 $parts = static::parse_url( $url );
861 $host = isset( $parts['host'] ) ? $parts['host'] : '';
862 $port = isset( $parts['port'] ) ? ":{$parts['port']}" : '';
863 $path = isset( $parts['path'] ) ? $parts['path'] : '';
864
865 return '//' . $host . $port . $path;
866 }
867
868 /**
869 * Remove scheme from URL.
870 *
871 * @param string $url
872 * @return string
873 */
874 public static function remove_scheme( $url ) {
875 return preg_replace( '/^(?:http|https):/', '', $url );
876 }
877
878 /**
879 * Remove size from filename (image[-100x100].jpeg).
880 *
881 * @param string $url
882 * @param bool $remove_extension
883 *
884 * @return string
885 */
886 public static function remove_size_from_filename( $url, $remove_extension = false ) {
887 $url = preg_replace( '/^(\S+)-[0-9]{1,4}x[0-9]{1,4}(\.[a-zA-Z0-9\.]{2,})?/', '$1$2', $url );
888
889 $url = apply_filters( 'wpmcs_remove_size_from_filename', $url );
890
891 if ( $remove_extension ) {
892 $ext = pathinfo( $url, PATHINFO_EXTENSION );
893 $url = str_replace( ".$ext", '', $url );
894 }
895
896 return $url;
897 }
898
899 /**
900 * Is the string a URL?
901 *
902 * @param mixed $string
903 *
904 * @return bool
905 */
906 public static function is_url( $string ): bool {
907 if ( empty( $string ) || ! is_string( $string ) ) {
908 return false;
909 }
910
911 if ( preg_match( '@^(?:https?:)?//[a-zA-Z0-9\-]+@', $string ) ) {
912 return true;
913 }
914
915 return false;
916 }
917
918 /**
919 * Parses a URL into its components. Compatible with PHP < 5.4.7.
920 *
921 * @param string $url The URL to parse.
922 *
923 * @param int $component PHP_URL_ constant for URL component to return.
924 *
925 * @return mixed An array of the parsed components, mixed for a requested component, or false on error.
926 */
927 public static function parse_url( $url, $component = -1 ) {
928 $url = trim( $url );
929 $no_scheme = 0 === strpos( $url, '//' );
930
931 if ( $no_scheme ) {
932 $url = 'http:' . $url;
933 }
934
935 $parts = parse_url( $url, $component );
936
937 if ( 0 < $component ) {
938 return $parts;
939 }
940
941 if ( $no_scheme && is_array( $parts ) ) {
942 unset( $parts['scheme'] );
943 }
944
945 return $parts;
946 }
947
948 /**
949 * Is the given string a usable URL?
950 *
951 * We need URLs that include at least a domain and filename with extension
952 * for URL rewriting in either direction.
953 *
954 * @param mixed $url
955 *
956 * @return bool
957 */
958 public static function is_file_url( $url ): bool {
959 if ( ! static::is_url( $url ) ) {
960 return false;
961 }
962
963 $parts = static::parse_url( $url );
964
965 if (
966 empty( $parts['host'] ) ||
967 empty( $parts['path'] ) ||
968 ! pathinfo( $parts['path'], PATHINFO_EXTENSION )
969 ) {
970 return false;
971 }
972
973 return true;
974 }
975
976
977 /**
978 * Remove query strings of services.
979 *
980 * @param string $content
981 * @param string $base_url Optional base URL that must exist within URL for Amazon query strings to be removed.
982 *
983 * @return string
984 */
985 public static function remove_query_strings( $content, $base_url = '' ) {
986 $pattern = '\?[^\s"<\?]*(?:X-Amz-Algorithm|AWSAccessKeyId|Key-Pair-Id|GoogleAccessId)=[^\s"<\?]+';
987 $group = 0;
988
989 if ( ! is_string( $content ) ) {
990 return $content;
991 }
992
993 if ( ! empty( $base_url ) ) {
994 $pattern = preg_quote( $base_url, '/' ) . '[^\s"<\?]+(' . $pattern . ')';
995 $group = 1;
996 }
997 if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches ) || ! isset( $matches[ $group ] ) ) {
998 // No query strings found, return
999 return $content;
1000 }
1001
1002 $matches = array_unique( $matches[ $group ] );
1003
1004 foreach ( $matches as $match ) {
1005 $content = str_replace( $match, '', $content );
1006 }
1007 return $content;
1008 }
1009
1010 /**
1011 * Maybe unserialize data, but not if an object.
1012 *
1013 * @param mixed $data
1014 *
1015 * @return mixed
1016 */
1017 public static function maybe_unserialize( $data ) {
1018 if ( is_serialized( $data ) ) {
1019 return @unserialize( $data, array( 'allowed_classes' => false ) ); // @phpcs:ignore
1020 }
1021
1022 return $data;
1023 }
1024
1025
1026 /**
1027 * Serialize data if needed.
1028 *
1029 * @param mixed $data
1030 * @return mixed
1031 */
1032 public static function maybe_serialize( $data ) {
1033 if ( is_array( $data ) || is_object( $data ) ) {
1034 return serialize( $data );
1035 }
1036
1037 // If it's not an array or object, don't serialize. If it is already serialized, return as is.
1038 if ( is_serialized( $data ) ) {
1039 return $data;
1040 }
1041
1042 return $data;
1043 }
1044
1045
1046 /**
1047 * Validate JSON
1048 */
1049 public static function is_json( $string ) {
1050 json_decode( $string );
1051 return ( json_last_error() == JSON_ERROR_NONE );
1052 }
1053
1054 /**
1055 * Check whether a specific class::method exists in the current call stack.
1056 *
1057 * Useful for detecting callers like WooCommerce image regeneration
1058 * without hard dependencies.
1059 *
1060 * @since 1.3.7
1061 * @param string $class Fully qualified class name.
1062 * @param string|null $function Method name (optional).
1063 * @param int $depth Backtrace depth limit.
1064 *
1065 * @return bool
1066 */
1067 public static function is_called_from(
1068 string $class,
1069 ?string $function = null,
1070 int $depth = 15
1071 ) : bool {
1072
1073 $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, $depth );
1074
1075 foreach ( $trace as $frame ) {
1076
1077 if ( empty( $frame['class'] ) ) {
1078 continue;
1079 }
1080
1081 if ( $frame['class'] !== $class ) {
1082 continue;
1083 }
1084
1085 // If function not specified, class match is enough
1086 if ( $function === null ) {
1087 return true;
1088 }
1089
1090 if ( isset( $frame['function'] ) && $frame['function'] === $function ) {
1091 return true;
1092 }
1093 }
1094
1095 return false;
1096 }
1097
1098
1099 /**
1100 * Is this an AJAX process?
1101 *
1102 * @return bool
1103 */
1104 public static function is_ajax() {
1105 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
1106 return true;
1107 }
1108
1109 return false;
1110 }
1111
1112 /**
1113 * Helper function for filtering super globals. Easily testable.
1114 *
1115 * @param string $variable
1116 * @param int $type
1117 * @param int $filter
1118 * @param mixed $options
1119 *
1120 * @return mixed
1121 */
1122 public static function filter_input( $variable, $type = INPUT_GET, $filter = FILTER_DEFAULT, $options = array() ) {
1123 return filter_input( $type, $variable, $filter, $options );
1124 }
1125
1126 /**
1127 * Get license data safe for frontend exposure (no raw key).
1128 *
1129 * @return array
1130 */
1131 public static function get_safe_license_data() {
1132 $data = get_option('wpmcs_pro_license', []);
1133 if (empty($data)) {
1134 return [];
1135 }
1136 $key = $data['license_key'] ?? '';
1137 $masked = '';
1138 if (!empty($key)) {
1139 $parts = explode('-', $key);
1140 if (count($parts) <= 2) {
1141 $masked = str_repeat('*', strlen($key));
1142 } else {
1143 $first = $parts[0];
1144 $last = end($parts);
1145 $middle_count = count($parts) - 2;
1146 $masked_middle = array_fill(0, $middle_count, '****');
1147 $masked = $first . '-' . implode('-', $masked_middle) . '-' . $last;
1148 }
1149 }
1150 return [
1151 'masked_key' => $masked,
1152 'status' => $data['status'] ?? '',
1153 'expiry' => $data['expiry'] ?? '',
1154 'is_expired' => $data['is_expired'] ?? false,
1155 'is_domain_activated' => $data['is_domain_activated'] ?? false,
1156 'can_activate' => $data['can_activate'] ?? false,
1157 'message' => $data['message'] ?? '',
1158 'last_checked' => $data['last_checked'] ?? 0,
1159 ];
1160 }
1161
1162 /**
1163 * Whether Pro is installed and currently licensed (active, domain-activated, not expired).
1164 * Single source of truth for this check — must match the frontend's isLicenseValid()
1165 * (app/src/helper/index.js) field-for-field so backend and frontend never disagree about
1166 * whether ajax/mixed sync mode is actually usable.
1167 * @since 1.3.13
1168 * @return bool
1169 */
1170 public static function is_pro_licensed() {
1171 if (!defined('WPMCS_PRO_VERSION')) {
1172 return false;
1173 }
1174
1175 $license = self::get_safe_license_data();
1176
1177 return ($license['status'] ?? '') === 'active'
1178 && ($license['is_domain_activated'] ?? false) === true
1179 && empty($license['is_expired']);
1180 }
1181
1182 // Cache-Control for newly uploaded objects; 1 month by default, custom duration is Pro-only, no-cache only when duration is explicitly 0.
1183 // @since 1.4.0
1184 public static function get_cache_control_header() {
1185 $duration = 1;
1186 $unit = 'months';
1187
1188 if (self::is_pro_licensed() && self::get_settings('cache_control_enabled', false)) {
1189 $duration = (int) self::get_settings('cache_control_duration', 1);
1190 $unit = self::get_settings('cache_control_unit', 'months');
1191 }
1192
1193 if ($duration <= 0) {
1194 return 'no-cache, no-store, must-revalidate';
1195 }
1196
1197 $unit_seconds = [
1198 'seconds' => 1,
1199 'minutes' => MINUTE_IN_SECONDS,
1200 'hours' => HOUR_IN_SECONDS,
1201 'days' => DAY_IN_SECONDS,
1202 'weeks' => WEEK_IN_SECONDS,
1203 'months' => MONTH_IN_SECONDS,
1204 'years' => YEAR_IN_SECONDS,
1205 ];
1206
1207 return 'public, max-age=' . ($duration * ($unit_seconds[$unit] ?? MONTH_IN_SECONDS));
1208 }
1209
1210 }
1211