PluginProbe
Media Cloud Sync / 1.4.2
Media Cloud Sync v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/config/utils.php +247 -48 1.3.11 → 1.4.2 View file →
@@ -66,9 +66,9 @@
66 66 global $wpdb;
67 67 if(!(!empty($key) || $post_id)) return false;
68 68
69 69 if($db_query) {
70 - $meta_data = $wpdb->get_row( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=$post_id AND meta_key='$key'" );
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 71 if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
72 72 return false;
73 73 }
74 74 return $meta_data->meta_value;
@@ -86,9 +86,9 @@
86 86 global $wpdb;
87 87 if(!(!empty($key))) return false;
88 88
89 89 if($db_query) {
90 - $meta_data = $wpdb->get_row( "SELECT option_value FROM $wpdb->options WHERE option_name='$key'" );
90 + $meta_data = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name=%s", $key ) );
91 91 if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
92 92 return false;
93 93 }
94 94 return $meta_data->option_value;
@@ -121,9 +121,9 @@
121 121 * @since 1.0.0
122 122 * @return array|boolean|string|integer|float|double
123 123 */
124 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 );
125 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, 'user' );
126 126 return $data === false ? $default : $data;
127 127 }
128 128
129 129 /**
@@ -131,9 +131,9 @@
131 131 * @since 1.0.0
132 132 * @return boolean
133 133 */
134 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 );
135 + return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, 'user' );
136 136 }
137 137
138 138 /**
139 139 * Function To delete Plugin Specific Wordpress user meta
@@ -140,13 +140,47 @@
140 140 * @since 1.0.0
141 141 * @return boolean
142 142 */
143 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 );
144 + return Cache::delete_object_cache( $key, $post_id, $meta_name, 'user' );
145 145 }
146 146
147 + /**
148 + * Function To get Plugin Specific meta via a caller-supplied storage backend
149 + * — for a meta table that isn't 'posts'/'users' and doesn't follow WP's
150 + * standard get_metadata() column conventions (e.g. BuddyBoss's groupmeta,
151 + * which uses its own get/update/delete functions internally).
152 + * @param array $backend ['get'=>callable, 'update'=>callable, 'delete'=>callable, 'prefix'=>string]
153 + * Each callable is shaped like get_post_meta($id,$key,true)/
154 + * update_post_meta($id,$key,$value)/delete_post_meta($id,$key).
155 + * @since 1.4.0.3
156 + * @return array|boolean|string|integer|float|double
157 + */
158 + public static function get_custom_meta($post_id, $key, $default = false, $meta_name = false, $expire = false, $backend = []){
159 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, $backend );
160 + return $data === false ? $default : $data;
161 + }
147 162
148 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 + /**
149 183 * Clear meta from database
150 184 *
151 185 * @param string|false $meta_name
152 186 * @param string $meta_table
@@ -171,9 +205,9 @@
171 205 if( in_array('usermeta', $meta_tables) ) {
172 206 // Clear user meta
173 207 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", $meta_name ) );
174 208 }
175 -
209 +
176 210 if( in_array('options', $meta_tables) ) {
177 211 // Clear options
178 212 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $meta_name ) );
179 213 }
@@ -266,27 +300,28 @@
266 300 }
267 301 }
268 302
269 303 /**
270 - * Check whether credentials are defined via the WPMCS_CONFIG constant in wp-config.php.
304 + * Check whether credentials are defined via a wp-config.php constant.
271 305 * @since 1.3.11
306 + * @param string $constant
272 307 * @return boolean
273 308 */
274 - public static function is_wp_config_credentials_defined(){
275 - return defined('WPMCS_CONFIG');
309 + public static function is_wp_config_credentials_defined($constant = 'WPMCS_CONFIG'){
310 + return defined($constant);
276 311 }
277 312
278 313 /**
279 - * Get credentials defined via the WPMCS_CONFIG constant in wp-config.php.
280 - * Accepts either a PHP array or a serialized string.
314 + * Get credentials defined via a wp-config.php constant. Accepts a PHP array or serialized string.
281 315 * @since 1.3.11
316 + * @param string $constant
282 317 * @return array
283 318 */
284 - public static function get_wp_config_credentials(){
285 - if(!defined('WPMCS_CONFIG')) {
319 + public static function get_wp_config_credentials($constant = 'WPMCS_CONFIG'){
320 + if(!defined($constant)) {
286 321 return [];
287 322 }
288 - $config = constant('WPMCS_CONFIG');
323 + $config = constant($constant);
289 324 if(is_string($config)) {
290 325 $config = self::maybe_unserialize($config);
291 326 }
292 327 return is_array($config) ? $config : [];
@@ -353,9 +388,10 @@
353 388 * @since 1.0.0
354 389 * @return array|boolean|string|integer|float|double
355 390 */
356 391 public static function get_status($option='', $default=false){
357 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
392 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
393 +
358 394 if(isset($current_setttings) && !empty($current_setttings)){
359 395 if(isset($option) && !empty($option)){
360 396 if(isset($current_setttings[$option])) {
361 397 return $current_setttings[$option];
@@ -380,9 +416,9 @@
380 416 return false;
381 417 }
382 418
383 419 $meta_name = Schema::getConstant('STATUS_KEY');
384 - $current_setttings = get_option($meta_name, []);
420 + $current_setttings = self::get_status('', []);
385 421
386 422 if(!is_array($current_setttings)) {
387 423 $current_setttings = [];
388 424 }
@@ -436,13 +472,27 @@
436 472 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
437 473 return (
438 474 self::is_service_enabled() &&
439 475 self::get_settings('rewrite_url') &&
440 - ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
476 + ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
441 477 );
442 478 }
443 479
444 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 + /**
445 495 * Function to check uploading media environment is ok
446 496 * @since 1.0.0
447 497 * @return boolean
448 498 */
@@ -573,8 +623,14 @@
573 623
574 624 // Normalize slashes early
575 625 $file = str_replace( '\\', '/', $file );
576 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 +
577 633 /**
578 634 * -------------------------------------------------
579 635 * TYPE: SOURCE (WordPress local paths / URLs)
580 636 * -------------------------------------------------
@@ -588,19 +644,26 @@
588 644
589 645 $basedir = str_replace( '\\', '/', $uploads['basedir'] );
590 646 $baseurl = str_replace( '\\', '/', $uploads['baseurl'] );
591 647
592 - // If URL → extract path
593 - if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
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 ) {
594 652 $parsed = wp_parse_url( $file );
595 653 $file = $parsed['path'] ?? '';
596 - }
597 654
598 - // Strip WordPress upload root
599 - if ( 0 === strpos( $file, $basedir ) ) {
600 - $file = substr( $file, strlen( $basedir ) );
601 - } elseif ( 0 === strpos( $file, $baseurl ) ) {
602 - $file = substr( $file, strlen( $baseurl ) );
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 + }
603 666 }
604 667 }
605 668
606 669 /**
@@ -610,9 +673,9 @@
610 673 */
611 674 elseif ( $type === 'key' ) {
612 675
613 676 // URL → extract path only
614 - if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
677 + if ( $is_url ) {
615 678 $parsed = wp_parse_url( $file );
616 679 $file = $parsed['path'] ?? '';
617 680 }
618 681
@@ -643,8 +706,15 @@
643 706 if ( $file === '' || substr( $file, -1 ) === '/' ) {
644 707 return false;
645 708 }
646 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 +
647 717 return apply_filters(
648 718 'wpmcs_get_relative_file_path_from_upload_directory',
649 719 $file,
650 720 $type
@@ -650,9 +720,35 @@
650 720 $type
651 721 );
652 722 }
653 723
724 + /**
725 + * Resolve a relative path (from get_attachment_source_path()) to an absolute path,
726 + * only if it genuinely stays within the uploads basedir — a defense-in-depth check
727 + * for callers about to file_exists()/read the result, alongside get_attachment_source_path()'s
728 + * own "..".
729 + * @since 1.4.1
730 + * @return string|false
731 + */
732 + public static function resolve_within_uploads( $relative_path ) {
733 + if ( empty( $relative_path ) || ! is_string( $relative_path ) ) {
734 + return false;
735 + }
654 736
737 + $basedir = trailingslashit( wp_get_upload_dir()['basedir'] );
738 + $absolute_path = $basedir . ltrim( $relative_path, '/' );
739 +
740 + $real_basedir = realpath( $basedir );
741 + $real_path = realpath( $absolute_path );
742 +
743 + if ( $real_basedir === false || $real_path === false || 0 !== strpos( $real_path, $real_basedir ) ) {
744 + return false;
745 + }
746 +
747 + return $absolute_path;
748 + }
749 +
750 +
655 751 /**
656 752 * Whether the file may be synced based on plugin extension settings only.
657 753 *
658 754 * Uses `extensions_exclude` to block listed extensions and optional `extensions_include` as an allow-list.
@@ -672,16 +768,26 @@
672 768 }
673 769
674 770 $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
675 771
676 - $allowed = [];
677 - if ( isset( $settings['extensions_include'] ) && is_array( $settings['extensions_include'] ) ) {
678 - $allowed = array_map( 'strtolower', array_filter( $settings['extensions_include'], 'strlen' ) );
679 - }
772 + $allowed = [];
773 + $not_allowed = [];
680 774
681 - $not_allowed = [];
682 - if ( isset( $settings['extensions_exclude'] ) && is_array( $settings['extensions_exclude'] ) ) {
683 - $not_allowed = array_map( 'strtolower', array_filter( $settings['extensions_exclude'], 'strlen' ) );
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 + }
684 790 }
685 791
686 792 if ( in_array( $ext, $not_allowed, true ) ) {
687 793 return false;
@@ -713,12 +819,22 @@
713 819 }
714 820
715 821
716 822 /**
823 + * Object key used for bucket permission checks.
824 + * Uses a .txt extension so CDN edge rules can serve the probe object.
825 + * @since 1.3.12
826 + * @return string
827 + */
828 + public static function get_permission_check_object_key() {
829 + return self::generate_object_key(WPMCS_TOKEN . '_dummy-object-for-bucket-permission-check.txt', '');
830 + }
831 +
832 + /**
717 833 * Generate Key for Objects
718 834 * @since 1.0.0
719 835 */
720 - public static function generate_object_key($relative_source_path, $prefix) {
836 + public static function generate_object_key($relative_source_path, $prefix, $is_private = false) {
721 837 $upload_path = '';
722 838 $enable_base_path = self::get_settings('enable_base_path', true);
723 839 $base_path = self::get_settings('base_path', 'wp-content/uploads');
724 840 $year_month = self::get_settings('year_month', true);
@@ -724,22 +840,40 @@
724 840 $year_month = self::get_settings('year_month', true);
725 841 $relative_source_path = ltrim( $relative_source_path, '/' );
726 842 $file_name = wp_basename( $relative_source_path );
727 843
728 - if(!$enable_base_path) { // If base path is not enabled
729 - $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 + }
730 872 }
731 873
732 874 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
733 875
734 - if(isset($base_path) && !empty($base_path)) {
735 - $upload_path.= preg_replace('~/+~', '/',
736 - str_replace('\\', '/',
737 - trim($base_path," \n\r\t\v\x00\/ ")
738 - )
739 - );
740 - }
741 -
742 876 if($keep_original_folder_structure) {
743 877 $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
744 878 } else {
745 879 if(isset($year_month) && $year_month) {
@@ -1029,22 +1163,26 @@
1029 1163 return ( json_last_error() == JSON_ERROR_NONE );
1030 1164 }
1031 1165
1032 1166 /**
1033 - * Check whether a specific class::method exists in the current call stack.
1167 + * Check whether a specific class::method, or a plain function, exists in
1168 + * the current call stack.
1034 1169 *
1035 1170 * Useful for detecting callers like WooCommerce image regeneration
1036 1171 * without hard dependencies.
1037 1172 *
1038 1173 * @since 1.3.7
1039 - * @param string $class Fully qualified class name.
1040 - * @param string|null $function Method name (optional).
1174 + * @since 1.4.2 $class made nullable; when null, matches on $function alone
1175 + * against frames with no class (plain procedural functions).
1176 + * @param string|null $class Fully qualified class name, or null to match
1177 + * a plain function by name instead.
1178 + * @param string|null $function Method (or, with $class null, function) name.
1041 1179 * @param int $depth Backtrace depth limit.
1042 1180 *
1043 1181 * @return bool
1044 1182 */
1045 1183 public static function is_called_from(
1046 - string $class,
1184 + ?string $class,
1047 1185 ?string $function = null,
1048 1186 int $depth = 15
1049 1187 ) : bool {
1050 1188
@@ -1051,8 +1189,21 @@
1051 1189 $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, $depth );
1052 1190
1053 1191 foreach ( $trace as $frame ) {
1054 1192
1193 + if ( $class === null ) {
1194 + // Match a plain function call — these frames have no 'class' key.
1195 + if ( ! empty( $frame['class'] ) ) {
1196 + continue;
1197 + }
1198 +
1199 + if ( isset( $frame['function'] ) && $frame['function'] === $function ) {
1200 + return true;
1201 + }
1202 +
1203 + continue;
1204 + }
1205 +
1055 1206 if ( empty( $frame['class'] ) ) {
1056 1207 continue;
1057 1208 }
1058 1209
@@ -1134,7 +1285,55 @@
1134 1285 'can_activate' => $data['can_activate'] ?? false,
1135 1286 'message' => $data['message'] ?? '',
1136 1287 'last_checked' => $data['last_checked'] ?? 0,
1137 1288 ];
1289 + }
1290 +
1291 + /**
1292 + * Whether Pro is installed and currently licensed (active, domain-activated, not expired).
1293 + * Single source of truth for this check — must match the frontend's isLicenseValid()
1294 + * (app/src/helper/index.js) field-for-field so backend and frontend never disagree about
1295 + * whether ajax/mixed sync mode is actually usable.
1296 + * @since 1.3.13
1297 + * @return bool
1298 + */
1299 + public static function is_pro_licensed() {
1300 + if (!defined('WPMCS_PRO_VERSION')) {
1301 + return false;
1302 + }
1303 +
1304 + $license = self::get_safe_license_data();
1305 +
1306 + return ($license['status'] ?? '') === 'active'
1307 + && ($license['is_domain_activated'] ?? false) === true
1308 + && empty($license['is_expired']);
1309 + }
1310 +
1311 + // Cache-Control for newly uploaded objects; 1 month by default, custom duration is Pro-only, no-cache only when duration is explicitly 0.
1312 + // @since 1.4.0
1313 + public static function get_cache_control_header() {
1314 + $duration = 1;
1315 + $unit = 'months';
1316 +
1317 + if (self::is_pro_licensed() && self::get_settings('cache_control_enabled', false)) {
1318 + $duration = (int) self::get_settings('cache_control_duration', 1);
1319 + $unit = self::get_settings('cache_control_unit', 'months');
1320 + }
1321 +
1322 + if ($duration <= 0) {
1323 + return 'no-cache, no-store, must-revalidate';
1324 + }
1325 +
1326 + $unit_seconds = [
1327 + 'seconds' => 1,
1328 + 'minutes' => MINUTE_IN_SECONDS,
1329 + 'hours' => HOUR_IN_SECONDS,
1330 + 'days' => DAY_IN_SECONDS,
1331 + 'weeks' => WEEK_IN_SECONDS,
1332 + 'months' => MONTH_IN_SECONDS,
1333 + 'years' => YEAR_IN_SECONDS,
1334 + ];
1335 +
1336 + return 'public, max-age=' . ($duration * ($unit_seconds[$unit] ?? MONTH_IN_SECONDS));
1138 1337 }
1139 1338
1140 1339 }