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 +837 -109 1.2.31.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 */
@@ -119,8 +383,53 @@
119 383 }
120 384 }
121 385
122 386 /**
387 + * Function To get Current statuses
388 + * @since 1.0.0
389 + * @return array|boolean|string|integer|float|double
390 + */
391 + public static function get_status($option='', $default=false){
392 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
393 +
394 + if(isset($current_setttings) && !empty($current_setttings)){
395 + if(isset($option) && !empty($option)){
396 + if(isset($current_setttings[$option])) {
397 + return $current_setttings[$option];
398 + } else {
399 + return $default;
400 + }
401 + } else {
402 + return $current_setttings;
403 + }
404 + } else {
405 + return $default;
406 + }
407 + }
408 +
409 + /**
410 + * Function To set statuses
411 + * @since 1.0.0
412 + *
413 + */
414 + public static function set_status($option='', $data=[]){
415 + if(!isset($option) || empty($option)){
416 + return false;
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);
429 + }
430 +
431 + /**
123 432 * Function To get Current Service
124 433 * @since 1.0.0
125 434 * @return array|boolean|string|integer|float|double
126 435 */
@@ -161,15 +470,29 @@
161 470 * @return boolean
162 471 */
163 472 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
164 473 return (
165 - self::get_service() &&
474 + self::is_service_enabled() &&
166 475 self::get_settings('rewrite_url') &&
167 - ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
476 + ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
168 477 );
169 478 }
170 479
171 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 + /**
172 495 * Function to check uploading media environment is ok
173 496 * @since 1.0.0
174 497 * @return boolean
175 498 */
@@ -174,9 +497,9 @@
174 497 * @return boolean
175 498 */
176 499 public static function is_ok_to_upload($attachment_id = false){
177 500 return (
178 - self::get_service() &&
501 + self::is_service_enabled() &&
179 502 self::get_settings('copy_to_bucket') &&
180 503 isset($attachment_id) && !empty($attachment_id)
181 504 );
182 505 }
@@ -181,14 +504,76 @@
181 504 );
182 505 }
183 506
184 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 + /**
185 570 * Function To check service is enabled
186 571 * @since 1.0.0
187 572 * @return array|boolean|string|integer|float|double
188 573 */
189 574 public static function is_service_enabled(){
190 - return !!self::get_service();
575 + return !!self::get_service() && self::has_valid_storage_credentials();
191 576 }
192 577
193 578 /**
194 579 * Check whether a file exist in a list of files
@@ -221,114 +606,198 @@
221 606
222 607 return false;
223 608 }
224 609
610 +
225 611 /**
226 - * Get Post Meta Data By Query
227 - * @since 1.0.0
228 - * @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
229 618 */
230 - public static function get_post_meta($post_id, $key, $single=false, $db_query=false){
231 - global $wpdb;
232 - 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 + }
233 623
234 - if($db_query) {
235 - $meta_data = $wpdb->get_row( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=$post_id AND meta_key='$key'" );
236 - 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'] ) ) {
237 642 return false;
238 643 }
239 - return $meta_data->meta_value;
240 - } else {
241 - 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 + }
242 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 + );
243 722 }
244 723
245 724 /**
246 - * Get Image URL and path from URL
247 - * Return Relative URL and Path of an attachment.
248 - * @since 1.0.0
249 - *
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
250 731 */
251 - public static function get_attachment_source_path($file) {
252 - if ( isset($file) && !empty($file) ) {
253 - $uploads = wp_get_upload_dir();
254 - $file_path = '';
255 - $site_url = site_url('/');
256 - $enable_base_path = self::get_settings('enable_base_path', true);
257 - $server_base_path = self::get_settings('base_path', 'wp-content/uploads');
258 - 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 + }
259 736
260 - $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, '/' );
261 739
262 - // Get URL and PATH
263 - if ( 0 === strpos( $file, $uploads['basedir'] ) || 0 === strpos( $file, $uploads['baseurl'] ) ) { // If URL is full link
264 - $file_path = str_replace( $uploads['basedir'], '', $file );
265 - $file_path = str_replace( $uploads['baseurl'], '', $file_path ); // Replace if has URl
266 - } else if (
267 - 0 === strpos( $file, str_replace('/','\\', $uploads['basedir'] )) ||
268 - 0 === strpos( $file, str_replace('\\','/', $uploads['baseurl'] ))
269 - ) { // If URL is full link and the url is slash unified (Like: str_replace('/','\\', $dir))
270 - $file_path = str_replace( str_replace('/','\\', $uploads['basedir'] ), '', $file );
271 - $file_path = str_replace( str_replace('\\','/', $uploads['baseurl'] ), '', $file_path ); // Replace if has URl
272 - $file_path = str_replace('\\','/', $file_path );
273 - } else if ( false !== strpos( $file, $uploadDir ) ) { //If URL has sub Directory That matches end of base URL(eg: wp-content/uploads)
274 - $fileDir = dirname( $file );
275 - $start_pos = strpos( $fileDir, $uploadDir ) + strlen($uploadDir);
276 - $subDir = substr( $fileDir, $start_pos, strlen($fileDir)); // Find Sub Directory
277 - $file_name = wp_basename( $file );
740 + $real_basedir = realpath( $basedir );
741 + $real_path = realpath( $absolute_path );
278 742
279 - $file_path = trailingslashit($subDir) . $file_name;
280 - } else if($enable_base_path && $server_base_path && false !== strpos( $file, trailingslashit($server_base_path))) {
281 - $fileDir = dirname( $file );
282 - $start_pos = strpos( $fileDir, $server_base_path) + strlen($server_base_path);
283 - $subDir = substr( $fileDir, $start_pos, strlen($fileDir)); // Find Sub Directory
284 - $file_name = wp_basename( $file );
743 + if ( $real_basedir === false || $real_path === false || 0 !== strpos( $real_path, $real_basedir ) ) {
744 + return false;
745 + }
285 746
286 - $file_path = trailingslashit($subDir) . $file_name;
287 - } else if(filter_var($file, FILTER_VALIDATE_URL)) {
288 - $parsed = parse_url($file);
289 - $path = isset($parsed["path"]) ? $parsed["path"] : '';
290 - $query = isset($parsed["query"]) ? '?'.$parsed["query"] : '';
291 - $file_path = $path. $query;
292 - } else {
293 - $file_path = $file;
294 - }
747 + return $absolute_path;
748 + }
295 749
296 - return apply_filters( 'wpmcs_get_relative_file_path_from_upload_directory', untrailingslashit(ltrim( $file_path, '/\\' )), $file );
297 - }
298 - }
299 - return false;
300 - }
301 750
302 751 /**
303 - * 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 + *
304 758 * @since 1.0.0
305 - * @return boolean
759 + * @param string $path Absolute or relative file path.
760 + * @return bool
306 761 */
307 - public static function is_extension_available($path){
762 + public static function is_extension_available( $path ) {
308 763 $settings = self::get_settings();
309 - $path_parts = pathinfo($path);
764 + $path_parts = pathinfo( $path );
310 765
311 - if(!isset($path_parts['basename']) || !isset($path_parts['extension'])) return false;
766 + if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
767 + return false;
768 + }
312 769
313 - $alowed = isset($settings['extensions_include']) ? $settings['extensions_include'] : [];
314 - $not_allowed = isset($settings['extensions_exclude']) ? $settings['extensions_exclude'] : [];
770 + $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
315 771
316 - if(
317 - (in_array($path_parts['extension'], $not_allowed)) ||
318 - (!empty($alowed) && !in_array($path_parts['extension'], $alowed))
319 - ) {
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 ) ) {
320 793 return false;
321 794 }
322 -
323 - $type_and_ext = wp_check_filetype_and_ext($path, $path_parts['basename']);
324 - $ext = empty( $type_and_ext['ext'] ) ? '' : $type_and_ext['ext'];
325 - $type = empty( $type_and_ext['type'] ) ? '' : $type_and_ext['type'];
326 795
327 - if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
328 - return false;
329 - }
330 -
796 + if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
797 + return false;
798 + }
799 +
331 800 return true;
332 801 }
333 802
334 803 /**
@@ -348,43 +817,68 @@
348 817
349 818 return $object_version;
350 819 }
351 820
821 +
352 822 /**
353 - * Generate Key for Objects
354 - * @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
355 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 +
356 832 /**
357 833 * Generate Key for Objects
358 834 * @since 1.0.0
359 835 */
360 - public static function generate_object_key($media_path, $prefix) {
361 - $upload_path = '';
362 - $enable_base_path = self::get_settings('enable_base_path', true);
363 - $base_path = self::get_settings('base_path', 'wp-content/uploads');
364 - $year_month = self::get_settings('year_month', true);
365 - $media_path = ltrim( $media_path, '/' );
366 - $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 );
367 843
368 - if(!$enable_base_path) { // If base path is not enabled
369 - $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 + }
370 872 }
371 873
372 874 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
373 875
374 - if(isset($base_path) && !empty($base_path)) {
375 - $upload_path.= preg_replace('~/+~', '/',
376 - str_replace('\\', '/',
377 - trim($base_path," \n\r\t\v\x00\/ ")
378 - )
379 - );
380 - }
381 -
382 876 if($keep_original_folder_structure) {
383 - $object_key = ltrim($upload_path . '/' . dirname( $media_path ) . '/' . $prefix . $file_name, '/');
877 + $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
384 878 } else {
385 879 if(isset($year_month) && $year_month) {
386 - $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);
387 881 if($year_month_prefix) {
388 882 $upload_path.= '/'.$year_month_prefix;
389 883 } else {
390 884 $upload_path.= '/'.date("Y/m");
@@ -393,9 +887,9 @@
393 887
394 888 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
395 889 }
396 890
397 - return apply_filters( 'wpmcs_object_key', $object_key, $media_path, $prefix );
891 + return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
398 892 }
399 893
400 894
401 895 /**
@@ -589,6 +1083,240 @@
589 1083 }
590 1084
591 1085 return true;
592 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 + }
593 1321
594 1322 }