PluginProbe
Media Cloud Sync / 1.4.1
Media Cloud Sync v1.4.1
1.4.1 1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 All 35 releases
← All changes | includes/config/utils.php +698 -120 1.2.131.4.1 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 */
@@ -80,10 +121,10 @@
80 121 * @since 1.0.0
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 - $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, true );
85 - return $data == false ? $default : $data;
125 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, 'user' );
126 + return $data === false ? $default : $data;
86 127 }
87 128
88 129 /**
89 130 * Function To update Plugin Specific Wordpress user meta
@@ -90,9 +131,9 @@
90 131 * @since 1.0.0
91 132 * @return boolean
92 133 */
93 134 public static function update_user_meta($post_id, $key, $options, $meta_name = false, $expire = false){
94 - return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, true );
135 + return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, 'user' );
95 136 }
96 137
97 138 /**
98 139 * Function To delete Plugin Specific Wordpress user meta
@@ -99,19 +140,153 @@
99 140 * @since 1.0.0
100 141 * @return boolean
101 142 */
102 143 public static function delete_user_meta($post_id, $key, $meta_name = false){
103 - return Cache::delete_object_cache( $key, $post_id, $meta_name, true );
144 + return Cache::delete_object_cache( $key, $post_id, $meta_name, 'user' );
104 145 }
105 146
106 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 + /**
107 270 * Function To get Current credentials
108 271 * @since 1.0.0
109 272 * @return array|boolean|string|integer|float|double
110 273 */
111 - public static function get_credentials($option='', $default=false){
274 + public static function get_credentials($option='', $default=false, $masked_config = false){
112 275 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
113 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 +
114 289 if(isset($option) && !empty($option)){
115 290 if(isset($current_setttings[$option])) {
116 291 return $current_setttings[$option];
117 292 } else {
@@ -125,8 +300,69 @@
125 300 }
126 301 }
127 302
128 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 + /**
129 365 * Function To get Current settings
130 366 * @since 1.0.0
131 367 * @return array|boolean|string|integer|float|double
132 368 */
@@ -152,9 +388,10 @@
152 388 * @since 1.0.0
153 389 * @return array|boolean|string|integer|float|double
154 390 */
155 391 public static function get_status($option='', $default=false){
156 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
392 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
393 +
157 394 if(isset($current_setttings) && !empty($current_setttings)){
158 395 if(isset($option) && !empty($option)){
159 396 if(isset($current_setttings[$option])) {
160 397 return $current_setttings[$option];
@@ -174,15 +411,22 @@
174 411 * @since 1.0.0
175 412 *
176 413 */
177 414 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 {
415 + if(!isset($option) || empty($option)){
183 416 return false;
184 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);
185 429 }
186 430
187 431 /**
188 432 * Function To get Current Service
@@ -226,15 +470,29 @@
226 470 * @return boolean
227 471 */
228 472 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
229 473 return (
230 - self::get_service() &&
474 + self::is_service_enabled() &&
231 475 self::get_settings('rewrite_url') &&
232 - ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
476 + ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
233 477 );
234 478 }
235 479
236 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 + /**
237 495 * Function to check uploading media environment is ok
238 496 * @since 1.0.0
239 497 * @return boolean
240 498 */
@@ -239,9 +497,9 @@
239 497 * @return boolean
240 498 */
241 499 public static function is_ok_to_upload($attachment_id = false){
242 500 return (
243 - self::get_service() &&
501 + self::is_service_enabled() &&
244 502 self::get_settings('copy_to_bucket') &&
245 503 isset($attachment_id) && !empty($attachment_id)
246 504 );
247 505 }
@@ -246,14 +504,76 @@
246 504 );
247 505 }
248 506
249 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 + /**
250 570 * Function To check service is enabled
251 571 * @since 1.0.0
252 572 * @return array|boolean|string|integer|float|double
253 573 */
254 574 public static function is_service_enabled(){
255 - return !!self::get_service();
575 + return !!self::get_service() && self::has_valid_storage_credentials();
256 576 }
257 577
258 578 /**
259 579 * Check whether a file exist in a list of files
@@ -286,114 +606,198 @@
286 606
287 607 return false;
288 608 }
289 609
610 +
290 611 /**
291 - * Get Post Meta Data By Query
292 - * @since 1.0.0
293 - * @return boolean
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
294 618 */
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;
619 + public static function get_attachment_source_path( $file, $type = 'source' ) {
620 + if ( empty( $file ) || ! is_string( $file ) ) {
621 + return false;
622 + }
298 623
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)) {
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'] ) ) {
302 642 return false;
303 643 }
304 - return $meta_data->meta_value;
305 - } else {
306 - return get_post_meta( $post_id, $key, $single );
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 + }
307 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 + );
308 722 }
309 723
310 724 /**
311 - * Get Image URL and path from URL
312 - * Return Relative URL and Path of an attachment.
313 - * @since 1.0.0
314 - *
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
315 731 */
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'] ) {
732 + public static function resolve_within_uploads( $relative_path ) {
733 + if ( empty( $relative_path ) || ! is_string( $relative_path ) ) {
734 + return false;
735 + }
324 736
325 - $uploadDir = substr( $uploads['baseurl'], strpos( $uploads['baseurl'], $site_url ) + strlen($site_url));
737 + $basedir = trailingslashit( wp_get_upload_dir()['basedir'] );
738 + $absolute_path = $basedir . ltrim( $relative_path, '/' );
326 739
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 );
740 + $real_basedir = realpath( $basedir );
741 + $real_path = realpath( $absolute_path );
343 742
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 );
743 + if ( $real_basedir === false || $real_path === false || 0 !== strpos( $real_path, $real_basedir ) ) {
744 + return false;
745 + }
350 746
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;
359 - }
747 + return $absolute_path;
748 + }
360 749
361 - return apply_filters( 'wpmcs_get_relative_file_path_from_upload_directory', untrailingslashit(ltrim( $file_path, '/\\' )), $file );
362 - }
363 - }
364 - return false;
365 - }
366 750
367 751 /**
368 - * Check extension is compatible
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 + *
369 758 * @since 1.0.0
370 - * @return boolean
759 + * @param string $path Absolute or relative file path.
760 + * @return bool
371 761 */
372 - public static function is_extension_available($path){
762 + public static function is_extension_available( $path ) {
373 763 $settings = self::get_settings();
374 - $path_parts = pathinfo($path);
764 + $path_parts = pathinfo( $path );
375 765
376 - if(!isset($path_parts['basename']) || !isset($path_parts['extension'])) return false;
766 + if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
767 + return false;
768 + }
377 769
378 - $alowed = isset($settings['extensions_include']) ? $settings['extensions_include'] : [];
379 - $not_allowed = isset($settings['extensions_exclude']) ? $settings['extensions_exclude'] : [];
770 + $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
380 771
381 - if(
382 - (in_array($path_parts['extension'], $not_allowed)) ||
383 - (!empty($alowed) && !in_array($path_parts['extension'], $alowed))
384 - ) {
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 ) ) {
385 793 return false;
386 794 }
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 795
392 - if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
393 - return false;
394 - }
395 -
796 + if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
797 + return false;
798 + }
799 +
396 800 return true;
397 801 }
398 802
399 803 /**
@@ -413,43 +817,68 @@
413 817
414 818 return $object_version;
415 819 }
416 820
821 +
417 822 /**
418 - * Generate Key for Objects
419 - * @since 1.0.0
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
420 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 +
421 832 /**
422 833 * Generate Key for Objects
423 834 * @since 1.0.0
424 835 */
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 );
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 );
432 843
433 - if(!$enable_base_path) { // If base path is not enabled
434 - $base_path = '';
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 + }
435 872 }
436 873
437 874 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
438 875
439 - if(isset($base_path) && !empty($base_path)) {
440 - $upload_path.= preg_replace('~/+~', '/',
441 - str_replace('\\', '/',
442 - trim($base_path," \n\r\t\v\x00\/ ")
443 - )
444 - );
445 - }
446 -
447 876 if($keep_original_folder_structure) {
448 - $object_key = ltrim($upload_path . '/' . dirname( $media_path ) . '/' . $prefix . $file_name, '/');
877 + $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
449 878 } else {
450 879 if(isset($year_month) && $year_month) {
451 - $year_month_prefix = self::get_year_month_from_file_path($media_path);
880 + $year_month_prefix = self::get_year_month_from_file_path($relative_source_path);
452 881 if($year_month_prefix) {
453 882 $upload_path.= '/'.$year_month_prefix;
454 883 } else {
455 884 $upload_path.= '/'.date("Y/m");
@@ -458,9 +887,9 @@
458 887
459 888 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
460 889 }
461 890
462 - return apply_filters( 'wpmcs_object_key', $object_key, $media_path, $prefix );
891 + return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
463 892 }
464 893
465 894
466 895 /**
@@ -689,9 +1118,9 @@
689 1118 }
690 1119 return $content;
691 1120 }
692 1121
693 - /**
1122 + /**
694 1123 * Maybe unserialize data, but not if an object.
695 1124 *
696 1125 * @param mixed $data
697 1126 *
@@ -706,8 +1135,28 @@
706 1135 }
707 1136
708 1137
709 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 + /**
710 1159 * Validate JSON
711 1160 */
712 1161 public static function is_json( $string ) {
713 1162 json_decode( $string );
@@ -714,8 +1163,53 @@
714 1163 return ( json_last_error() == JSON_ERROR_NONE );
715 1164 }
716 1165
717 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 + /**
718 1212 * Is this an AJAX process?
719 1213 *
720 1214 * @return bool
721 1215 */
@@ -738,7 +1232,91 @@
738 1232 * @return mixed
739 1233 */
740 1234 public static function filter_input( $variable, $type = INPUT_GET, $filter = FILTER_DEFAULT, $options = array() ) {
741 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));
742 1320 }
743 1321
744 1322 }