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
← All changes | includes/config/utils.php +573 -107 1.2.12trunk View file →
@@ -25,9 +25,9 @@
25 25 * @return array|boolean|string|integer|float|double
26 26 */
27 27 public static function get_option($key, $default = false, $meta_name = false, $expire = false){
28 28 $data = Cache::get_object_cache( $key, false, $meta_name, $expire );
29 - return $data == false ? $default : $data;
29 + return $data === false ? $default : $data;
30 30 }
31 31
32 32 /**
33 33 * Function To update Plugin Specific Wordpress Option
@@ -53,12 +53,53 @@
53 53 * @return array|boolean|string|integer|float|double
54 54 */
55 55 public static function get_meta($post_id, $key, $default = false, $meta_name = false, $expire = false){
56 56 $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire );
57 - return $data == false ? $default : $data;
57 + return $data === false ? $default : $data;
58 58 }
59 59
60 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 + /**
61 102 * Function To update Plugin Specific Wordpress post meta
62 103 * @since 1.0.0
63 104 * @return boolean
64 105 */
@@ -81,9 +122,9 @@
81 122 * @return array|boolean|string|integer|float|double
82 123 */
83 124 public static function get_user_meta($post_id, $key, $default = false, $meta_name = false, $expire = false){
84 125 $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, true );
85 - return $data == false ? $default : $data;
126 + return $data === false ? $default : $data;
86 127 }
87 128
88 129 /**
89 130 * Function To update Plugin Specific Wordpress user meta
@@ -102,16 +143,116 @@
102 143 public static function delete_user_meta($post_id, $key, $meta_name = false){
103 144 return Cache::delete_object_cache( $key, $post_id, $meta_name, true );
104 145 }
105 146
147 +
106 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 + /**
107 236 * Function To get Current credentials
108 237 * @since 1.0.0
109 238 * @return array|boolean|string|integer|float|double
110 239 */
111 - public static function get_credentials($option='', $default=false){
240 + public static function get_credentials($option='', $default=false, $masked_config = false){
112 241 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
113 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 +
114 255 if(isset($option) && !empty($option)){
115 256 if(isset($current_setttings[$option])) {
116 257 return $current_setttings[$option];
117 258 } else {
@@ -125,8 +266,69 @@
125 266 }
126 267 }
127 268
128 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 + /**
129 331 * Function To get Current settings
130 332 * @since 1.0.0
131 333 * @return array|boolean|string|integer|float|double
132 334 */
@@ -152,9 +354,10 @@
152 354 * @since 1.0.0
153 355 * @return array|boolean|string|integer|float|double
154 356 */
155 357 public static function get_status($option='', $default=false){
156 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
358 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
359 +
157 360 if(isset($current_setttings) && !empty($current_setttings)){
158 361 if(isset($option) && !empty($option)){
159 362 if(isset($current_setttings[$option])) {
160 363 return $current_setttings[$option];
@@ -174,15 +377,22 @@
174 377 * @since 1.0.0
175 378 *
176 379 */
177 380 public static function set_status($option='', $data=[]){
178 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
179 - if(isset($option) && !empty($option)){
180 - $current_setttings[$option] = $data;
181 - return self::update_option('status', $current_setttings, Schema::getConstant('STATUS_KEY'));
182 - } else {
381 + if(!isset($option) || empty($option)){
183 382 return false;
184 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);
185 395 }
186 396
187 397 /**
188 398 * Function To get Current Service
@@ -226,9 +436,9 @@
226 436 * @return boolean
227 437 */
228 438 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
229 439 return (
230 - self::get_service() &&
440 + self::is_service_enabled() &&
231 441 self::get_settings('rewrite_url') &&
232 442 ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
233 443 );
234 444 }
@@ -239,9 +449,9 @@
239 449 * @return boolean
240 450 */
241 451 public static function is_ok_to_upload($attachment_id = false){
242 452 return (
243 - self::get_service() &&
453 + self::is_service_enabled() &&
244 454 self::get_settings('copy_to_bucket') &&
245 455 isset($attachment_id) && !empty($attachment_id)
246 456 );
247 457 }
@@ -246,14 +456,76 @@
246 456 );
247 457 }
248 458
249 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 + /**
250 522 * Function To check service is enabled
251 523 * @since 1.0.0
252 524 * @return array|boolean|string|integer|float|double
253 525 */
254 526 public static function is_service_enabled(){
255 - return !!self::get_service();
527 + return !!self::get_service() && self::has_valid_storage_credentials();
256 528 }
257 529
258 530 /**
259 531 * Check whether a file exist in a list of files
@@ -286,114 +558,152 @@
286 558
287 559 return false;
288 560 }
289 561
562 +
290 563 /**
291 - * Get Post Meta Data By Query
292 - * @since 1.0.0
293 - * @return boolean
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
294 570 */
295 - public static function get_post_meta($post_id, $key, $single=false, $db_query=false){
296 - global $wpdb;
297 - if(!(!empty($key) || $post_id)) return false;
571 + public static function get_attachment_source_path( $file, $type = 'source' ) {
572 + if ( empty( $file ) || ! is_string( $file ) ) {
573 + return false;
574 + }
298 575
299 - if($db_query) {
300 - $meta_data = $wpdb->get_row( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=$post_id AND meta_key='$key'" );
301 - if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
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'] ) ) {
302 588 return false;
303 589 }
304 - return $meta_data->meta_value;
305 - } else {
306 - return get_post_meta( $post_id, $key, $single );
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 + }
307 606 }
308 - }
309 607
310 - /**
311 - * Get Image URL and path from URL
312 - * Return Relative URL and Path of an attachment.
313 - * @since 1.0.0
314 - *
315 - */
316 - public static function get_attachment_source_path($file) {
317 - if ( isset($file) && !empty($file) ) {
318 - $uploads = wp_get_upload_dir();
319 - $file_path = '';
320 - $site_url = site_url('/');
321 - $enable_base_path = self::get_settings('enable_base_path', true);
322 - $server_base_path = self::get_settings('base_path', 'wp-content/uploads');
323 - if ( $uploads && false === $uploads['error'] ) {
608 + /**
609 + * -------------------------------------------------
610 + * TYPE: KEY (Cloud / CDN paths or URLs)
611 + * -------------------------------------------------
612 + */
613 + elseif ( $type === 'key' ) {
324 614
325 - $uploadDir = substr( $uploads['baseurl'], strpos( $uploads['baseurl'], $site_url ) + strlen($site_url));
615 + // URL → extract path only
616 + if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
617 + $parsed = wp_parse_url( $file );
618 + $file = $parsed['path'] ?? '';
619 + }
326 620
327 - // Get URL and PATH
328 - if ( 0 === strpos( $file, $uploads['basedir'] ) || 0 === strpos( $file, $uploads['baseurl'] ) ) { // If URL is full link
329 - $file_path = str_replace( $uploads['basedir'], '', $file );
330 - $file_path = str_replace( $uploads['baseurl'], '', $file_path ); // Replace if has URl
331 - } else if (
332 - 0 === strpos( $file, str_replace('/','\\', $uploads['basedir'] )) ||
333 - 0 === strpos( $file, str_replace('\\','/', $uploads['baseurl'] ))
334 - ) { // If URL is full link and the url is slash unified (Like: str_replace('/','\\', $dir))
335 - $file_path = str_replace( str_replace('/','\\', $uploads['basedir'] ), '', $file );
336 - $file_path = str_replace( str_replace('\\','/', $uploads['baseurl'] ), '', $file_path ); // Replace if has URl
337 - $file_path = str_replace('\\','/', $file_path );
338 - } else if ( false !== strpos( $file, $uploadDir ) ) { //If URL has sub Directory That matches end of base URL(eg: wp-content/uploads)
339 - $fileDir = dirname( $file );
340 - $start_pos = strpos( $fileDir, $uploadDir ) + strlen($uploadDir);
341 - $subDir = substr( $fileDir, $start_pos, strlen($fileDir)); // Find Sub Directory
342 - $file_name = wp_basename( $file );
621 + $file = ltrim( $file, '/' );
343 622
344 - $file_path = trailingslashit($subDir) . $file_name;
345 - } else if($enable_base_path && $server_base_path && false !== strpos( $file, trailingslashit($server_base_path))) {
346 - $fileDir = dirname( $file );
347 - $start_pos = strpos( $fileDir, $server_base_path) + strlen($server_base_path);
348 - $subDir = substr( $fileDir, $start_pos, strlen($fileDir)); // Find Sub Directory
349 - $file_name = wp_basename( $file );
623 + $enable_base_path = self::get_settings( 'enable_base_path', true );
624 + $base_path = trim( (string) self::get_settings( 'base_path', '' ), '/' );
350 625
351 - $file_path = trailingslashit($subDir) . $file_name;
352 - } else if(filter_var($file, FILTER_VALIDATE_URL)) {
353 - $parsed = parse_url($file);
354 - $path = isset($parsed["path"]) ? $parsed["path"] : '';
355 - $query = isset($parsed["query"]) ? '?'.$parsed["query"] : '';
356 - $file_path = $path. $query;
357 - } else {
358 - $file_path = $file;
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] );
359 635 }
636 + }
637 + }
360 638
361 - return apply_filters( 'wpmcs_get_relative_file_path_from_upload_directory', untrailingslashit(ltrim( $file_path, '/\\' )), $file );
362 - }
363 - }
364 - return false;
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 + );
365 654 }
366 655
656 +
367 657 /**
368 - * Check extension is compatible
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 + *
369 664 * @since 1.0.0
370 - * @return boolean
665 + * @param string $path Absolute or relative file path.
666 + * @return bool
371 667 */
372 - public static function is_extension_available($path){
668 + public static function is_extension_available( $path ) {
373 669 $settings = self::get_settings();
374 - $path_parts = pathinfo($path);
670 + $path_parts = pathinfo( $path );
375 671
376 - if(!isset($path_parts['basename']) || !isset($path_parts['extension'])) return false;
672 + if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
673 + return false;
674 + }
377 675
378 - $alowed = isset($settings['extensions_include']) ? $settings['extensions_include'] : [];
379 - $not_allowed = isset($settings['extensions_exclude']) ? $settings['extensions_exclude'] : [];
676 + $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
380 677
381 - if(
382 - (in_array($path_parts['extension'], $not_allowed)) ||
383 - (!empty($alowed) && !in_array($path_parts['extension'], $alowed))
384 - ) {
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 ) ) {
385 699 return false;
386 700 }
387 -
388 - $type_and_ext = wp_check_filetype_and_ext($path, $path_parts['basename']);
389 - $ext = empty( $type_and_ext['ext'] ) ? '' : $type_and_ext['ext'];
390 - $type = empty( $type_and_ext['type'] ) ? '' : $type_and_ext['type'];
391 701
392 - if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
393 - return false;
394 - }
395 -
702 + if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
703 + return false;
704 + }
705 +
396 706 return true;
397 707 }
398 708
399 709 /**
@@ -413,23 +723,30 @@
413 723
414 724 return $object_version;
415 725 }
416 726
727 +
417 728 /**
418 - * Generate Key for Objects
419 - * @since 1.0.0
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
420 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 +
421 738 /**
422 739 * Generate Key for Objects
423 740 * @since 1.0.0
424 741 */
425 - public static function generate_object_key($media_path, $prefix) {
426 - $upload_path = '';
427 - $enable_base_path = self::get_settings('enable_base_path', true);
428 - $base_path = self::get_settings('base_path', 'wp-content/uploads');
429 - $year_month = self::get_settings('year_month', true);
430 - $media_path = ltrim( $media_path, '/' );
431 - $file_name = wp_basename( $media_path );
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 );
432 749
433 750 if(!$enable_base_path) { // If base path is not enabled
434 751 $base_path = '';
435 752 }
@@ -444,12 +761,12 @@
444 761 );
445 762 }
446 763
447 764 if($keep_original_folder_structure) {
448 - $object_key = ltrim($upload_path . '/' . dirname( $media_path ) . '/' . $prefix . $file_name, '/');
765 + $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
449 766 } else {
450 767 if(isset($year_month) && $year_month) {
451 - $year_month_prefix = self::get_year_month_from_file_path($media_path);
768 + $year_month_prefix = self::get_year_month_from_file_path($relative_source_path);
452 769 if($year_month_prefix) {
453 770 $upload_path.= '/'.$year_month_prefix;
454 771 } else {
455 772 $upload_path.= '/'.date("Y/m");
@@ -458,9 +775,9 @@
458 775
459 776 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
460 777 }
461 778
462 - return apply_filters( 'wpmcs_object_key', $object_key, $media_path, $prefix );
779 + return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
463 780 }
464 781
465 782
466 783 /**
@@ -689,9 +1006,9 @@
689 1006 }
690 1007 return $content;
691 1008 }
692 1009
693 - /**
1010 + /**
694 1011 * Maybe unserialize data, but not if an object.
695 1012 *
696 1013 * @param mixed $data
697 1014 *
@@ -706,8 +1023,28 @@
706 1023 }
707 1024
708 1025
709 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 + /**
710 1047 * Validate JSON
711 1048 */
712 1049 public static function is_json( $string ) {
713 1050 json_decode( $string );
@@ -714,8 +1051,53 @@
714 1051 return ( json_last_error() == JSON_ERROR_NONE );
715 1052 }
716 1053
717 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 + /**
718 1100 * Is this an AJAX process?
719 1101 *
720 1102 * @return bool
721 1103 */
@@ -738,7 +1120,91 @@
738 1120 * @return mixed
739 1121 */
740 1122 public static function filter_input( $variable, $type = INPUT_GET, $filter = FILTER_DEFAULT, $options = array() ) {
741 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));
742 1208 }
743 1209
744 1210 }