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 +806 -115 1.2.91.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 */
@@ -75,15 +116,177 @@
75 116 return Cache::delete_object_cache( $key, $post_id, $meta_name );
76 117 }
77 118
78 119 /**
120 + * Function To get Plugin Specific Wordpress user meta
121 + * @since 1.0.0
122 + * @return array|boolean|string|integer|float|double
123 + */
124 + public static function get_user_meta($post_id, $key, $default = false, $meta_name = false, $expire = false){
125 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, 'user' );
126 + return $data === false ? $default : $data;
127 + }
128 +
129 + /**
130 + * Function To update Plugin Specific Wordpress user meta
131 + * @since 1.0.0
132 + * @return boolean
133 + */
134 + public static function update_user_meta($post_id, $key, $options, $meta_name = false, $expire = false){
135 + return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, 'user' );
136 + }
137 +
138 + /**
139 + * Function To delete Plugin Specific Wordpress user meta
140 + * @since 1.0.0
141 + * @return boolean
142 + */
143 + public static function delete_user_meta($post_id, $key, $meta_name = false){
144 + return Cache::delete_object_cache( $key, $post_id, $meta_name, 'user' );
145 + }
146 +
147 + /**
148 + * Function To get Plugin Specific meta via a caller-supplied storage backend
149 + * — for a meta table that isn't 'posts'/'users' and doesn't follow WP's
150 + * standard get_metadata() column conventions (e.g. BuddyBoss's groupmeta,
151 + * which uses its own get/update/delete functions internally).
152 + * @param array $backend ['get'=>callable, 'update'=>callable, 'delete'=>callable, 'prefix'=>string]
153 + * Each callable is shaped like get_post_meta($id,$key,true)/
154 + * update_post_meta($id,$key,$value)/delete_post_meta($id,$key).
155 + * @since 1.4.0.3
156 + * @return array|boolean|string|integer|float|double
157 + */
158 + public static function get_custom_meta($post_id, $key, $default = false, $meta_name = false, $expire = false, $backend = []){
159 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, $backend );
160 + return $data === false ? $default : $data;
161 + }
162 +
163 + /**
164 + * Function To update Plugin Specific meta via a caller-supplied storage backend. See get_custom_meta().
165 + * @since 1.4.0.3
166 + * @return boolean
167 + */
168 + public static function update_custom_meta($post_id, $key, $options, $meta_name = false, $expire = false, $backend = []){
169 + return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, $backend );
170 + }
171 +
172 + /**
173 + * Function To delete Plugin Specific meta via a caller-supplied storage backend. See get_custom_meta().
174 + * @since 1.4.0.3
175 + * @return boolean
176 + */
177 + public static function delete_custom_meta($post_id, $key, $meta_name = false, $backend = []){
178 + return Cache::delete_object_cache( $key, $post_id, $meta_name, $backend );
179 + }
180 +
181 +
182 + /**
183 + * Clear meta from database
184 + *
185 + * @param string|false $meta_name
186 + * @param string $meta_table
187 + * @param bool $flush_cache Whether to flush the plugin object cache afterward.
188 + */
189 + public static function clear_all_meta($meta_name = false, $meta_table = 'all', $flush_cache = true) {
190 + global $wpdb;
191 +
192 + $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('META_KEY') : $meta_name;
193 +
194 + if ( empty( $meta_name ) ) {
195 + return false; // Avoid accidental deletions if the meta_key is empty
196 + }
197 +
198 + $meta_tables = $meta_table == 'all' ? ['postmeta', 'usermeta', 'options'] : [$meta_table];
199 +
200 + if( in_array('postmeta', $meta_tables) ) {
201 + // Clear post meta
202 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
203 + }
204 +
205 + if( in_array('usermeta', $meta_tables) ) {
206 + // Clear user meta
207 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", $meta_name ) );
208 + }
209 +
210 + if( in_array('options', $meta_tables) ) {
211 + // Clear options
212 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $meta_name ) );
213 + }
214 +
215 + self::invalidate_core_meta_cache($meta_tables, $meta_name);
216 +
217 + if ($flush_cache) {
218 + Cache::flush_object_cache();
219 + }
220 +
221 + return true;
222 + }
223 +
224 + /**
225 + * Clears all content meta from the database
226 + *
227 + * @param string|false $meta_name Optional. The meta key to clear. Defaults to the constant CONTENT_META_KEY.
228 + * @param bool $flush_cache Whether to flush the plugin object cache afterward.
229 + */
230 + public static function clear_all_content_meta($meta_name = false, $flush_cache = true) {
231 + global $wpdb;
232 + $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('CONTENT_META_KEY') : $meta_name;
233 +
234 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
235 +
236 + self::invalidate_core_meta_cache(['postmeta'], $meta_name);
237 +
238 + if ($flush_cache) {
239 + Cache::flush_object_cache();
240 + }
241 +
242 + return true;
243 + }
244 +
245 + /**
246 + * Invalidate WordPress core object caches after direct SQL meta deletes.
247 + */
248 + private static function invalidate_core_meta_cache($meta_tables, $meta_name) {
249 + if (!function_exists('wp_cache_delete')) {
250 + return;
251 + }
252 +
253 + if (in_array('options', $meta_tables, true)) {
254 + wp_cache_delete('alloptions', 'options');
255 + wp_cache_delete($meta_name, 'options');
256 + }
257 +
258 + if (in_array('postmeta', $meta_tables, true)) {
259 + if (function_exists('wp_cache_set_last_changed')) {
260 + wp_cache_set_last_changed('posts');
261 + } elseif (function_exists('wp_cache_delete')) {
262 + wp_cache_delete('last_changed', 'posts');
263 + }
264 + }
265 + }
266 +
267 +
268 +
269 + /**
79 270 * Function To get Current credentials
80 271 * @since 1.0.0
81 272 * @return array|boolean|string|integer|float|double
82 273 */
83 - public static function get_credentials($option='', $default=false){
274 + public static function get_credentials($option='', $default=false, $masked_config = false){
84 275 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
85 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 +
86 289 if(isset($option) && !empty($option)){
87 290 if(isset($current_setttings[$option])) {
88 291 return $current_setttings[$option];
89 292 } else {
@@ -97,8 +300,69 @@
97 300 }
98 301 }
99 302
100 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 + /**
101 365 * Function To get Current settings
102 366 * @since 1.0.0
103 367 * @return array|boolean|string|integer|float|double
104 368 */
@@ -124,9 +388,10 @@
124 388 * @since 1.0.0
125 389 * @return array|boolean|string|integer|float|double
126 390 */
127 391 public static function get_status($option='', $default=false){
128 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
392 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
393 +
129 394 if(isset($current_setttings) && !empty($current_setttings)){
130 395 if(isset($option) && !empty($option)){
131 396 if(isset($current_setttings[$option])) {
132 397 return $current_setttings[$option];
@@ -146,15 +411,22 @@
146 411 * @since 1.0.0
147 412 *
148 413 */
149 414 public static function set_status($option='', $data=[]){
150 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
151 - if(isset($option) && !empty($option)){
152 - $current_setttings[$option] = $data;
153 - return self::update_option('status', $current_setttings, Schema::getConstant('STATUS_KEY'));
154 - } else {
415 + if(!isset($option) || empty($option)){
155 416 return false;
156 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);
157 429 }
158 430
159 431 /**
160 432 * Function To get Current Service
@@ -198,15 +470,29 @@
198 470 * @return boolean
199 471 */
200 472 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
201 473 return (
202 - self::get_service() &&
474 + self::is_service_enabled() &&
203 475 self::get_settings('rewrite_url') &&
204 - ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
476 + ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
205 477 );
206 478 }
207 479
208 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 + /**
209 495 * Function to check uploading media environment is ok
210 496 * @since 1.0.0
211 497 * @return boolean
212 498 */
@@ -211,9 +497,9 @@
211 497 * @return boolean
212 498 */
213 499 public static function is_ok_to_upload($attachment_id = false){
214 500 return (
215 - self::get_service() &&
501 + self::is_service_enabled() &&
216 502 self::get_settings('copy_to_bucket') &&
217 503 isset($attachment_id) && !empty($attachment_id)
218 504 );
219 505 }
@@ -218,14 +504,76 @@
218 504 );
219 505 }
220 506
221 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 + /**
222 570 * Function To check service is enabled
223 571 * @since 1.0.0
224 572 * @return array|boolean|string|integer|float|double
225 573 */
226 574 public static function is_service_enabled(){
227 - return !!self::get_service();
575 + return !!self::get_service() && self::has_valid_storage_credentials();
228 576 }
229 577
230 578 /**
231 579 * Check whether a file exist in a list of files
@@ -258,114 +606,198 @@
258 606
259 607 return false;
260 608 }
261 609
610 +
262 611 /**
263 - * Get Post Meta Data By Query
264 - * @since 1.0.0
265 - * @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
266 618 */
267 - public static function get_post_meta($post_id, $key, $single=false, $db_query=false){
268 - global $wpdb;
269 - 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 + }
270 623
271 - if($db_query) {
272 - $meta_data = $wpdb->get_row( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=$post_id AND meta_key='$key'" );
273 - 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'] ) ) {
274 642 return false;
275 643 }
276 - return $meta_data->meta_value;
277 - } else {
278 - 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 + }
279 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 + );
280 722 }
281 723
282 724 /**
283 - * Get Image URL and path from URL
284 - * Return Relative URL and Path of an attachment.
285 - * @since 1.0.0
286 - *
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
287 731 */
288 - public static function get_attachment_source_path($file) {
289 - if ( isset($file) && !empty($file) ) {
290 - $uploads = wp_get_upload_dir();
291 - $file_path = '';
292 - $site_url = site_url('/');
293 - $enable_base_path = self::get_settings('enable_base_path', true);
294 - $server_base_path = self::get_settings('base_path', 'wp-content/uploads');
295 - 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 + }
296 736
297 - $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, '/' );
298 739
299 - // Get URL and PATH
300 - if ( 0 === strpos( $file, $uploads['basedir'] ) || 0 === strpos( $file, $uploads['baseurl'] ) ) { // If URL is full link
301 - $file_path = str_replace( $uploads['basedir'], '', $file );
302 - $file_path = str_replace( $uploads['baseurl'], '', $file_path ); // Replace if has URl
303 - } else if (
304 - 0 === strpos( $file, str_replace('/','\\', $uploads['basedir'] )) ||
305 - 0 === strpos( $file, str_replace('\\','/', $uploads['baseurl'] ))
306 - ) { // If URL is full link and the url is slash unified (Like: str_replace('/','\\', $dir))
307 - $file_path = str_replace( str_replace('/','\\', $uploads['basedir'] ), '', $file );
308 - $file_path = str_replace( str_replace('\\','/', $uploads['baseurl'] ), '', $file_path ); // Replace if has URl
309 - $file_path = str_replace('\\','/', $file_path );
310 - } else if ( false !== strpos( $file, $uploadDir ) ) { //If URL has sub Directory That matches end of base URL(eg: wp-content/uploads)
311 - $fileDir = dirname( $file );
312 - $start_pos = strpos( $fileDir, $uploadDir ) + strlen($uploadDir);
313 - $subDir = substr( $fileDir, $start_pos, strlen($fileDir)); // Find Sub Directory
314 - $file_name = wp_basename( $file );
740 + $real_basedir = realpath( $basedir );
741 + $real_path = realpath( $absolute_path );
315 742
316 - $file_path = trailingslashit($subDir) . $file_name;
317 - } else if($enable_base_path && $server_base_path && false !== strpos( $file, trailingslashit($server_base_path))) {
318 - $fileDir = dirname( $file );
319 - $start_pos = strpos( $fileDir, $server_base_path) + strlen($server_base_path);
320 - $subDir = substr( $fileDir, $start_pos, strlen($fileDir)); // Find Sub Directory
321 - $file_name = wp_basename( $file );
743 + if ( $real_basedir === false || $real_path === false || 0 !== strpos( $real_path, $real_basedir ) ) {
744 + return false;
745 + }
322 746
323 - $file_path = trailingslashit($subDir) . $file_name;
324 - } else if(filter_var($file, FILTER_VALIDATE_URL)) {
325 - $parsed = parse_url($file);
326 - $path = isset($parsed["path"]) ? $parsed["path"] : '';
327 - $query = isset($parsed["query"]) ? '?'.$parsed["query"] : '';
328 - $file_path = $path. $query;
329 - } else {
330 - $file_path = $file;
331 - }
747 + return $absolute_path;
748 + }
332 749
333 - return apply_filters( 'wpmcs_get_relative_file_path_from_upload_directory', untrailingslashit(ltrim( $file_path, '/\\' )), $file );
334 - }
335 - }
336 - return false;
337 - }
338 750
339 751 /**
340 - * 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 + *
341 758 * @since 1.0.0
342 - * @return boolean
759 + * @param string $path Absolute or relative file path.
760 + * @return bool
343 761 */
344 - public static function is_extension_available($path){
762 + public static function is_extension_available( $path ) {
345 763 $settings = self::get_settings();
346 - $path_parts = pathinfo($path);
764 + $path_parts = pathinfo( $path );
347 765
348 - if(!isset($path_parts['basename']) || !isset($path_parts['extension'])) return false;
766 + if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
767 + return false;
768 + }
349 769
350 - $alowed = isset($settings['extensions_include']) ? $settings['extensions_include'] : [];
351 - $not_allowed = isset($settings['extensions_exclude']) ? $settings['extensions_exclude'] : [];
770 + $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
352 771
353 - if(
354 - (in_array($path_parts['extension'], $not_allowed)) ||
355 - (!empty($alowed) && !in_array($path_parts['extension'], $alowed))
356 - ) {
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 ) ) {
357 793 return false;
358 794 }
359 -
360 - $type_and_ext = wp_check_filetype_and_ext($path, $path_parts['basename']);
361 - $ext = empty( $type_and_ext['ext'] ) ? '' : $type_and_ext['ext'];
362 - $type = empty( $type_and_ext['type'] ) ? '' : $type_and_ext['type'];
363 795
364 - if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
365 - return false;
366 - }
367 -
796 + if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
797 + return false;
798 + }
799 +
368 800 return true;
369 801 }
370 802
371 803 /**
@@ -385,43 +817,68 @@
385 817
386 818 return $object_version;
387 819 }
388 820
821 +
389 822 /**
390 - * Generate Key for Objects
391 - * @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
392 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 +
393 832 /**
394 833 * Generate Key for Objects
395 834 * @since 1.0.0
396 835 */
397 - public static function generate_object_key($media_path, $prefix) {
398 - $upload_path = '';
399 - $enable_base_path = self::get_settings('enable_base_path', true);
400 - $base_path = self::get_settings('base_path', 'wp-content/uploads');
401 - $year_month = self::get_settings('year_month', true);
402 - $media_path = ltrim( $media_path, '/' );
403 - $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 );
404 843
405 - if(!$enable_base_path) { // If base path is not enabled
406 - $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 + }
407 872 }
408 873
409 874 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
410 875
411 - if(isset($base_path) && !empty($base_path)) {
412 - $upload_path.= preg_replace('~/+~', '/',
413 - str_replace('\\', '/',
414 - trim($base_path," \n\r\t\v\x00\/ ")
415 - )
416 - );
417 - }
418 -
419 876 if($keep_original_folder_structure) {
420 - $object_key = ltrim($upload_path . '/' . dirname( $media_path ) . '/' . $prefix . $file_name, '/');
877 + $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
421 878 } else {
422 879 if(isset($year_month) && $year_month) {
423 - $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);
424 881 if($year_month_prefix) {
425 882 $upload_path.= '/'.$year_month_prefix;
426 883 } else {
427 884 $upload_path.= '/'.date("Y/m");
@@ -430,9 +887,9 @@
430 887
431 888 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
432 889 }
433 890
434 - return apply_filters( 'wpmcs_object_key', $object_key, $media_path, $prefix );
891 + return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
435 892 }
436 893
437 894
438 895 /**
@@ -626,6 +1083,240 @@
626 1083 }
627 1084
628 1085 return true;
629 1086 }
1087 +
1088 +
1089 + /**
1090 + * Remove query strings of services.
1091 + *
1092 + * @param string $content
1093 + * @param string $base_url Optional base URL that must exist within URL for Amazon query strings to be removed.
1094 + *
1095 + * @return string
1096 + */
1097 + public static function remove_query_strings( $content, $base_url = '' ) {
1098 + $pattern = '\?[^\s"<\?]*(?:X-Amz-Algorithm|AWSAccessKeyId|Key-Pair-Id|GoogleAccessId)=[^\s"<\?]+';
1099 + $group = 0;
1100 +
1101 + if ( ! is_string( $content ) ) {
1102 + return $content;
1103 + }
1104 +
1105 + if ( ! empty( $base_url ) ) {
1106 + $pattern = preg_quote( $base_url, '/' ) . '[^\s"<\?]+(' . $pattern . ')';
1107 + $group = 1;
1108 + }
1109 + if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches ) || ! isset( $matches[ $group ] ) ) {
1110 + // No query strings found, return
1111 + return $content;
1112 + }
1113 +
1114 + $matches = array_unique( $matches[ $group ] );
1115 +
1116 + foreach ( $matches as $match ) {
1117 + $content = str_replace( $match, '', $content );
1118 + }
1119 + return $content;
1120 + }
1121 +
1122 + /**
1123 + * Maybe unserialize data, but not if an object.
1124 + *
1125 + * @param mixed $data
1126 + *
1127 + * @return mixed
1128 + */
1129 + public static function maybe_unserialize( $data ) {
1130 + if ( is_serialized( $data ) ) {
1131 + return @unserialize( $data, array( 'allowed_classes' => false ) ); // @phpcs:ignore
1132 + }
1133 +
1134 + return $data;
1135 + }
1136 +
1137 +
1138 + /**
1139 + * Serialize data if needed.
1140 + *
1141 + * @param mixed $data
1142 + * @return mixed
1143 + */
1144 + public static function maybe_serialize( $data ) {
1145 + if ( is_array( $data ) || is_object( $data ) ) {
1146 + return serialize( $data );
1147 + }
1148 +
1149 + // If it's not an array or object, don't serialize. If it is already serialized, return as is.
1150 + if ( is_serialized( $data ) ) {
1151 + return $data;
1152 + }
1153 +
1154 + return $data;
1155 + }
1156 +
1157 +
1158 + /**
1159 + * Validate JSON
1160 + */
1161 + public static function is_json( $string ) {
1162 + json_decode( $string );
1163 + return ( json_last_error() == JSON_ERROR_NONE );
1164 + }
1165 +
1166 + /**
1167 + * Check whether a specific class::method exists in the current call stack.
1168 + *
1169 + * Useful for detecting callers like WooCommerce image regeneration
1170 + * without hard dependencies.
1171 + *
1172 + * @since 1.3.7
1173 + * @param string $class Fully qualified class name.
1174 + * @param string|null $function Method name (optional).
1175 + * @param int $depth Backtrace depth limit.
1176 + *
1177 + * @return bool
1178 + */
1179 + public static function is_called_from(
1180 + string $class,
1181 + ?string $function = null,
1182 + int $depth = 15
1183 + ) : bool {
1184 +
1185 + $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, $depth );
1186 +
1187 + foreach ( $trace as $frame ) {
1188 +
1189 + if ( empty( $frame['class'] ) ) {
1190 + continue;
1191 + }
1192 +
1193 + if ( $frame['class'] !== $class ) {
1194 + continue;
1195 + }
1196 +
1197 + // If function not specified, class match is enough
1198 + if ( $function === null ) {
1199 + return true;
1200 + }
1201 +
1202 + if ( isset( $frame['function'] ) && $frame['function'] === $function ) {
1203 + return true;
1204 + }
1205 + }
1206 +
1207 + return false;
1208 + }
1209 +
1210 +
1211 + /**
1212 + * Is this an AJAX process?
1213 + *
1214 + * @return bool
1215 + */
1216 + public static function is_ajax() {
1217 + if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
1218 + return true;
1219 + }
1220 +
1221 + return false;
1222 + }
1223 +
1224 + /**
1225 + * Helper function for filtering super globals. Easily testable.
1226 + *
1227 + * @param string $variable
1228 + * @param int $type
1229 + * @param int $filter
1230 + * @param mixed $options
1231 + *
1232 + * @return mixed
1233 + */
1234 + public static function filter_input( $variable, $type = INPUT_GET, $filter = FILTER_DEFAULT, $options = array() ) {
1235 + return filter_input( $type, $variable, $filter, $options );
1236 + }
1237 +
1238 + /**
1239 + * Get license data safe for frontend exposure (no raw key).
1240 + *
1241 + * @return array
1242 + */
1243 + public static function get_safe_license_data() {
1244 + $data = get_option('wpmcs_pro_license', []);
1245 + if (empty($data)) {
1246 + return [];
1247 + }
1248 + $key = $data['license_key'] ?? '';
1249 + $masked = '';
1250 + if (!empty($key)) {
1251 + $parts = explode('-', $key);
1252 + if (count($parts) <= 2) {
1253 + $masked = str_repeat('*', strlen($key));
1254 + } else {
1255 + $first = $parts[0];
1256 + $last = end($parts);
1257 + $middle_count = count($parts) - 2;
1258 + $masked_middle = array_fill(0, $middle_count, '****');
1259 + $masked = $first . '-' . implode('-', $masked_middle) . '-' . $last;
1260 + }
1261 + }
1262 + return [
1263 + 'masked_key' => $masked,
1264 + 'status' => $data['status'] ?? '',
1265 + 'expiry' => $data['expiry'] ?? '',
1266 + 'is_expired' => $data['is_expired'] ?? false,
1267 + 'is_domain_activated' => $data['is_domain_activated'] ?? false,
1268 + 'can_activate' => $data['can_activate'] ?? false,
1269 + 'message' => $data['message'] ?? '',
1270 + 'last_checked' => $data['last_checked'] ?? 0,
1271 + ];
1272 + }
1273 +
1274 + /**
1275 + * Whether Pro is installed and currently licensed (active, domain-activated, not expired).
1276 + * Single source of truth for this check — must match the frontend's isLicenseValid()
1277 + * (app/src/helper/index.js) field-for-field so backend and frontend never disagree about
1278 + * whether ajax/mixed sync mode is actually usable.
1279 + * @since 1.3.13
1280 + * @return bool
1281 + */
1282 + public static function is_pro_licensed() {
1283 + if (!defined('WPMCS_PRO_VERSION')) {
1284 + return false;
1285 + }
1286 +
1287 + $license = self::get_safe_license_data();
1288 +
1289 + return ($license['status'] ?? '') === 'active'
1290 + && ($license['is_domain_activated'] ?? false) === true
1291 + && empty($license['is_expired']);
1292 + }
1293 +
1294 + // Cache-Control for newly uploaded objects; 1 month by default, custom duration is Pro-only, no-cache only when duration is explicitly 0.
1295 + // @since 1.4.0
1296 + public static function get_cache_control_header() {
1297 + $duration = 1;
1298 + $unit = 'months';
1299 +
1300 + if (self::is_pro_licensed() && self::get_settings('cache_control_enabled', false)) {
1301 + $duration = (int) self::get_settings('cache_control_duration', 1);
1302 + $unit = self::get_settings('cache_control_unit', 'months');
1303 + }
1304 +
1305 + if ($duration <= 0) {
1306 + return 'no-cache, no-store, must-revalidate';
1307 + }
1308 +
1309 + $unit_seconds = [
1310 + 'seconds' => 1,
1311 + 'minutes' => MINUTE_IN_SECONDS,
1312 + 'hours' => HOUR_IN_SECONDS,
1313 + 'days' => DAY_IN_SECONDS,
1314 + 'weeks' => WEEK_IN_SECONDS,
1315 + 'months' => MONTH_IN_SECONDS,
1316 + 'years' => YEAR_IN_SECONDS,
1317 + ];
1318 +
1319 + return 'public, max-age=' . ($duration * ($unit_seconds[$unit] ?? MONTH_IN_SECONDS));
1320 + }
630 1321
631 1322 }