PluginProbe
Media Cloud Sync / trunk
Media Cloud Sync vtrunk
1.4.0 1.3.12 1.3.11 1.3.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.2.0 1.2.10 1.2.11 1.2.12 1.2.13 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3.0 All 34 releases
← All changes | includes/config/utils.php +715 -99 1.2.2trunk 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,143 @@
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, true );
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, true );
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, true );
145 + }
146 +
147 +
148 + /**
149 + * Clear meta from database
150 + *
151 + * @param string|false $meta_name
152 + * @param string $meta_table
153 + * @param bool $flush_cache Whether to flush the plugin object cache afterward.
154 + */
155 + public static function clear_all_meta($meta_name = false, $meta_table = 'all', $flush_cache = true) {
156 + global $wpdb;
157 +
158 + $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('META_KEY') : $meta_name;
159 +
160 + if ( empty( $meta_name ) ) {
161 + return false; // Avoid accidental deletions if the meta_key is empty
162 + }
163 +
164 + $meta_tables = $meta_table == 'all' ? ['postmeta', 'usermeta', 'options'] : [$meta_table];
165 +
166 + if( in_array('postmeta', $meta_tables) ) {
167 + // Clear post meta
168 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
169 + }
170 +
171 + if( in_array('usermeta', $meta_tables) ) {
172 + // Clear user meta
173 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", $meta_name ) );
174 + }
175 +
176 + if( in_array('options', $meta_tables) ) {
177 + // Clear options
178 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $meta_name ) );
179 + }
180 +
181 + self::invalidate_core_meta_cache($meta_tables, $meta_name);
182 +
183 + if ($flush_cache) {
184 + Cache::flush_object_cache();
185 + }
186 +
187 + return true;
188 + }
189 +
190 + /**
191 + * Clears all content meta from the database
192 + *
193 + * @param string|false $meta_name Optional. The meta key to clear. Defaults to the constant CONTENT_META_KEY.
194 + * @param bool $flush_cache Whether to flush the plugin object cache afterward.
195 + */
196 + public static function clear_all_content_meta($meta_name = false, $flush_cache = true) {
197 + global $wpdb;
198 + $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('CONTENT_META_KEY') : $meta_name;
199 +
200 + $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
201 +
202 + self::invalidate_core_meta_cache(['postmeta'], $meta_name);
203 +
204 + if ($flush_cache) {
205 + Cache::flush_object_cache();
206 + }
207 +
208 + return true;
209 + }
210 +
211 + /**
212 + * Invalidate WordPress core object caches after direct SQL meta deletes.
213 + */
214 + private static function invalidate_core_meta_cache($meta_tables, $meta_name) {
215 + if (!function_exists('wp_cache_delete')) {
216 + return;
217 + }
218 +
219 + if (in_array('options', $meta_tables, true)) {
220 + wp_cache_delete('alloptions', 'options');
221 + wp_cache_delete($meta_name, 'options');
222 + }
223 +
224 + if (in_array('postmeta', $meta_tables, true)) {
225 + if (function_exists('wp_cache_set_last_changed')) {
226 + wp_cache_set_last_changed('posts');
227 + } elseif (function_exists('wp_cache_delete')) {
228 + wp_cache_delete('last_changed', 'posts');
229 + }
230 + }
231 + }
232 +
233 +
234 +
235 + /**
79 236 * Function To get Current credentials
80 237 * @since 1.0.0
81 238 * @return array|boolean|string|integer|float|double
82 239 */
83 - public static function get_credentials($option='', $default=false){
240 + public static function get_credentials($option='', $default=false, $masked_config = false){
84 241 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
85 242 if(isset($current_setttings) && !empty($current_setttings)){
243 + // Resolve the credential source. Defaults to 'database' for backward compatibility.
244 + $source = isset($current_setttings['configSource']) ? $current_setttings['configSource'] : 'database';
245 +
246 + if($source === 'config') {
247 + // Credentials live in the WPMCS_CONFIG constant (wp-config.php).
248 + // Server-side consumers receive the real values; REST-facing (masked) callers
249 + // receive nothing so the constant contents are never exposed to the browser.
250 + $current_setttings['config'] = $masked_config ? [] : self::get_wp_config_credentials();
251 + } elseif($masked_config && isset($current_setttings['config'])) {
252 + $current_setttings['config'] = self::mask_config($current_setttings['config']);
253 + }
254 +
86 255 if(isset($option) && !empty($option)){
87 256 if(isset($current_setttings[$option])) {
88 257 return $current_setttings[$option];
89 258 } else {
@@ -97,8 +266,69 @@
97 266 }
98 267 }
99 268
100 269 /**
270 + * Check whether credentials are defined via a wp-config.php constant.
271 + * @since 1.3.11
272 + * @param string $constant
273 + * @return boolean
274 + */
275 + public static function is_wp_config_credentials_defined($constant = 'WPMCS_CONFIG'){
276 + return defined($constant);
277 + }
278 +
279 + /**
280 + * Get credentials defined via a wp-config.php constant. Accepts a PHP array or serialized string.
281 + * @since 1.3.11
282 + * @param string $constant
283 + * @return array
284 + */
285 + public static function get_wp_config_credentials($constant = 'WPMCS_CONFIG'){
286 + if(!defined($constant)) {
287 + return [];
288 + }
289 + $config = constant($constant);
290 + if(is_string($config)) {
291 + $config = self::maybe_unserialize($config);
292 + }
293 + return is_array($config) ? $config : [];
294 + }
295 +
296 + /**
297 + * Get the current credential source ('database' | 'config').
298 + * Defaults to 'database' for backward compatibility.
299 + * @since 1.3.11
300 + * @return string
301 + */
302 + public static function get_credentials_source(){
303 + $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
304 + return isset($current_setttings['configSource']) ? $current_setttings['configSource'] : 'database';
305 + }
306 +
307 + /**
308 + * Mask Config
309 + * @since 1.2.13
310 + * @return array|boolean|string|integer|float|double
311 + */
312 + public static function mask_config($config){
313 + foreach ($config as $key => $value) {
314 + if (in_array($key, ['config_json', 'secret_key'])) {
315 + $config[$key] = substr($value, 0, 4) . self::mask_string(substr($value, 4));
316 + }
317 + }
318 + return $config;
319 + }
320 +
321 + /**
322 + * Mask String
323 + * @since 1.2.13
324 + * @return array|boolean|string|integer|float|double
325 + */
326 + public static function mask_string($string){
327 + return str_repeat('*', strlen($string));
328 + }
329 +
330 + /**
101 331 * Function To get Current settings
102 332 * @since 1.0.0
103 333 * @return array|boolean|string|integer|float|double
104 334 */
@@ -119,8 +349,53 @@
119 349 }
120 350 }
121 351
122 352 /**
353 + * Function To get Current statuses
354 + * @since 1.0.0
355 + * @return array|boolean|string|integer|float|double
356 + */
357 + public static function get_status($option='', $default=false){
358 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
359 +
360 + if(isset($current_setttings) && !empty($current_setttings)){
361 + if(isset($option) && !empty($option)){
362 + if(isset($current_setttings[$option])) {
363 + return $current_setttings[$option];
364 + } else {
365 + return $default;
366 + }
367 + } else {
368 + return $current_setttings;
369 + }
370 + } else {
371 + return $default;
372 + }
373 + }
374 +
375 + /**
376 + * Function To set statuses
377 + * @since 1.0.0
378 + *
379 + */
380 + public static function set_status($option='', $data=[]){
381 + if(!isset($option) || empty($option)){
382 + return false;
383 + }
384 +
385 + $meta_name = Schema::getConstant('STATUS_KEY');
386 + $current_setttings = self::get_status('', []);
387 +
388 + if(!is_array($current_setttings)) {
389 + $current_setttings = [];
390 + }
391 +
392 + $current_setttings[$option] = $data;
393 +
394 + return self::update_option('status', $current_setttings, $meta_name);
395 + }
396 +
397 + /**
123 398 * Function To get Current Service
124 399 * @since 1.0.0
125 400 * @return array|boolean|string|integer|float|double
126 401 */
@@ -161,9 +436,9 @@
161 436 * @return boolean
162 437 */
163 438 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
164 439 return (
165 - self::get_service() &&
440 + self::is_service_enabled() &&
166 441 self::get_settings('rewrite_url') &&
167 442 ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
168 443 );
169 444 }
@@ -174,9 +449,9 @@
174 449 * @return boolean
175 450 */
176 451 public static function is_ok_to_upload($attachment_id = false){
177 452 return (
178 - self::get_service() &&
453 + self::is_service_enabled() &&
179 454 self::get_settings('copy_to_bucket') &&
180 455 isset($attachment_id) && !empty($attachment_id)
181 456 );
182 457 }
@@ -181,14 +456,76 @@
181 456 );
182 457 }
183 458
184 459 /**
460 + * Whether stored credentials are complete for the configured service.
461 + * @since 1.3.11
462 + * @return boolean
463 + */
464 + private static function has_valid_storage_credentials() {
465 + return self::get_service_configuration_error() === '';
466 + }
467 +
468 + /**
469 + * Human-readable error when storage credentials are incomplete.
470 + * @since 1.3.11
471 + * @return string Empty when valid.
472 + */
473 + public static function get_service_configuration_error() {
474 + $service = self::get_service();
475 + if(!$service) {
476 + return '';
477 + }
478 +
479 + $credentials = self::get_credentials('', [], false);
480 + $bucketConfig = isset($credentials['bucketConfig']) ? $credentials['bucketConfig'] : [];
481 +
482 + if(empty($bucketConfig['bucket_name'])) {
483 + return esc_html__('Bucket name is not configured.', 'media-cloud-sync');
484 + }
485 +
486 + $configSource = self::get_credentials_source();
487 +
488 + if($configSource === 'config') {
489 + if(!self::is_wp_config_credentials_defined()) {
490 + return esc_html__('WPMCS_CONFIG is not defined in wp-config.php', 'media-cloud-sync');
491 + }
492 +
493 + $config = self::get_wp_config_credentials();
494 + $missing = [];
495 + foreach(Service::get_required_config_keys($service) as $key) {
496 + if(!isset($config[$key]) || $config[$key] === '') {
497 + $missing[] = $key;
498 + }
499 + }
500 + if(!empty($missing)) {
501 + /* translators: %s: comma separated list of missing configuration keys */
502 + return sprintf(esc_html__('WPMCS_CONFIG is missing key(s): %s', 'media-cloud-sync'), implode(', ', $missing));
503 + }
504 + } else {
505 + $config = isset($credentials['config']) ? $credentials['config'] : [];
506 + $missing = [];
507 + foreach(Service::get_required_config_keys($service) as $key) {
508 + if(!isset($config[$key]) || $config[$key] === '') {
509 + $missing[] = $key;
510 + }
511 + }
512 + if(!empty($missing)) {
513 + /* translators: %s: comma separated list of missing configuration keys */
514 + return sprintf(esc_html__('Storage credentials are incomplete. Missing key(s): %s', 'media-cloud-sync'), implode(', ', $missing));
515 + }
516 + }
517 +
518 + return '';
519 + }
520 +
521 + /**
185 522 * Function To check service is enabled
186 523 * @since 1.0.0
187 524 * @return array|boolean|string|integer|float|double
188 525 */
189 526 public static function is_service_enabled(){
190 - return !!self::get_service();
527 + return !!self::get_service() && self::has_valid_storage_credentials();
191 528 }
192 529
193 530 /**
194 531 * Check whether a file exist in a list of files
@@ -221,114 +558,152 @@
221 558
222 559 return false;
223 560 }
224 561
562 +
225 563 /**
226 - * Get Post Meta Data By Query
227 - * @since 1.0.0
228 - * @return boolean
564 + * Get relative attachment path for local source or remote object key.
565 + *
566 + * @param string $file File path, URL, or object key
567 + * @param string $type 'source' (local WP) or 'key' (cloud / CDN)
568 + *
569 + * @return string|false
229 570 */
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;
571 + public static function get_attachment_source_path( $file, $type = 'source' ) {
572 + if ( empty( $file ) || ! is_string( $file ) ) {
573 + return false;
574 + }
233 575
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)) {
576 + // Normalize slashes early
577 + $file = str_replace( '\\', '/', $file );
578 +
579 + /**
580 + * -------------------------------------------------
581 + * TYPE: SOURCE (WordPress local paths / URLs)
582 + * -------------------------------------------------
583 + */
584 + if ( $type === 'source' ) {
585 +
586 + $uploads = wp_get_upload_dir();
587 + if ( empty( $uploads ) || ! empty( $uploads['error'] ) ) {
237 588 return false;
238 589 }
239 - return $meta_data->meta_value;
240 - } else {
241 - return get_post_meta( $post_id, $key, $single );
590 +
591 + $basedir = str_replace( '\\', '/', $uploads['basedir'] );
592 + $baseurl = str_replace( '\\', '/', $uploads['baseurl'] );
593 +
594 + // If URL → extract path
595 + if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
596 + $parsed = wp_parse_url( $file );
597 + $file = $parsed['path'] ?? '';
598 + }
599 +
600 + // Strip WordPress upload root
601 + if ( 0 === strpos( $file, $basedir ) ) {
602 + $file = substr( $file, strlen( $basedir ) );
603 + } elseif ( 0 === strpos( $file, $baseurl ) ) {
604 + $file = substr( $file, strlen( $baseurl ) );
605 + }
242 606 }
243 - }
244 607
245 - /**
246 - * Get Image URL and path from URL
247 - * Return Relative URL and Path of an attachment.
248 - * @since 1.0.0
249 - *
250 - */
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'] ) {
608 + /**
609 + * -------------------------------------------------
610 + * TYPE: KEY (Cloud / CDN paths or URLs)
611 + * -------------------------------------------------
612 + */
613 + elseif ( $type === 'key' ) {
259 614
260 - $uploadDir = substr( $uploads['baseurl'], strpos( $uploads['baseurl'], $site_url ) + strlen($site_url));
615 + // URL → extract path only
616 + if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
617 + $parsed = wp_parse_url( $file );
618 + $file = $parsed['path'] ?? '';
619 + }
261 620
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 );
621 + $file = ltrim( $file, '/' );
278 622
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 );
623 + $enable_base_path = self::get_settings( 'enable_base_path', true );
624 + $base_path = trim( (string) self::get_settings( 'base_path', '' ), '/' );
285 625
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;
626 + /**
627 + * If base_path is enabled and exists as a FULL segment,
628 + * strip everything before it.
629 + */
630 + if ( $enable_base_path && $base_path !== '' ) {
631 + $pattern = '#(^|/)' . preg_quote( $base_path, '#' ) . '(/|$)#';
632 +
633 + if ( preg_match( $pattern, $file, $m, PREG_OFFSET_CAPTURE ) ) {
634 + $file = substr( $file, $m[0][1] );
294 635 }
636 + }
637 + }
295 638
296 - return apply_filters( 'wpmcs_get_relative_file_path_from_upload_directory', untrailingslashit(ltrim( $file_path, '/\\' )), $file );
297 - }
298 - }
299 - return false;
639 + // Final cleanup
640 + $file = trim( $file, '/' );
641 +
642 + /**
643 + * Reject directory-only paths
644 + */
645 + if ( $file === '' || substr( $file, -1 ) === '/' ) {
646 + return false;
647 + }
648 +
649 + return apply_filters(
650 + 'wpmcs_get_relative_file_path_from_upload_directory',
651 + $file,
652 + $type
653 + );
300 654 }
301 655
656 +
302 657 /**
303 - * Check extension is compatible
658 + * Whether the file may be synced based on plugin extension settings only.
659 + *
660 + * Uses `extensions_exclude` to block listed extensions and optional `extensions_include` as an allow-list.
661 + * When `extensions_include` is empty, no extension is blocked by the allow-list (only exclude applies).
662 + * WordPress MIME / `wp_check_filetype` is not used here.
663 + *
304 664 * @since 1.0.0
305 - * @return boolean
665 + * @param string $path Absolute or relative file path.
666 + * @return bool
306 667 */
307 - public static function is_extension_available($path){
668 + public static function is_extension_available( $path ) {
308 669 $settings = self::get_settings();
309 - $path_parts = pathinfo($path);
670 + $path_parts = pathinfo( $path );
310 671
311 - if(!isset($path_parts['basename']) || !isset($path_parts['extension'])) return false;
672 + if ( ! isset( $path_parts['basename'] ) || $path_parts['basename'] === '' ) {
673 + return false;
674 + }
312 675
313 - $alowed = isset($settings['extensions_include']) ? $settings['extensions_include'] : [];
314 - $not_allowed = isset($settings['extensions_exclude']) ? $settings['extensions_exclude'] : [];
676 + $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
315 677
316 - if(
317 - (in_array($path_parts['extension'], $not_allowed)) ||
318 - (!empty($alowed) && !in_array($path_parts['extension'], $alowed))
319 - ) {
678 + $allowed = [];
679 + $not_allowed = [];
680 +
681 + // Settings UI for these two fields is Pro-only; the values shouldn't apply without a license.
682 + if ( self::is_pro_licensed() ) {
683 + if (
684 + ! empty( $settings['extensions_include_enabled'] ) &&
685 + isset( $settings['extensions_include'] ) && is_array( $settings['extensions_include'] )
686 + ) {
687 + $allowed = array_map( 'strtolower', array_filter( $settings['extensions_include'], 'strlen' ) );
688 + }
689 +
690 + if (
691 + ! empty( $settings['extensions_exclude_enabled'] ) &&
692 + isset( $settings['extensions_exclude'] ) && is_array( $settings['extensions_exclude'] )
693 + ) {
694 + $not_allowed = array_map( 'strtolower', array_filter( $settings['extensions_exclude'], 'strlen' ) );
695 + }
696 + }
697 +
698 + if ( in_array( $ext, $not_allowed, true ) ) {
320 699 return false;
321 700 }
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 701
327 - if ( ( ! $type || ! $ext ) && ! current_user_can( 'unfiltered_upload' ) ) {
328 - return false;
329 - }
330 -
702 + if ( ! empty( $allowed ) && ! in_array( $ext, $allowed, true ) ) {
703 + return false;
704 + }
705 +
331 706 return true;
332 707 }
333 708
334 709 /**
@@ -348,23 +723,30 @@
348 723
349 724 return $object_version;
350 725 }
351 726
727 +
352 728 /**
353 - * Generate Key for Objects
354 - * @since 1.0.0
729 + * Object key used for bucket permission checks.
730 + * Uses a .txt extension so CDN edge rules can serve the probe object.
731 + * @since 1.3.12
732 + * @return string
355 733 */
734 + public static function get_permission_check_object_key() {
735 + return self::generate_object_key(WPMCS_TOKEN . '_dummy-object-for-bucket-permission-check.txt', '');
736 + }
737 +
356 738 /**
357 739 * Generate Key for Objects
358 740 * @since 1.0.0
359 741 */
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 );
742 + public static function generate_object_key($relative_source_path, $prefix) {
743 + $upload_path = '';
744 + $enable_base_path = self::get_settings('enable_base_path', true);
745 + $base_path = self::get_settings('base_path', 'wp-content/uploads');
746 + $year_month = self::get_settings('year_month', true);
747 + $relative_source_path = ltrim( $relative_source_path, '/' );
748 + $file_name = wp_basename( $relative_source_path );
367 749
368 750 if(!$enable_base_path) { // If base path is not enabled
369 751 $base_path = '';
370 752 }
@@ -379,12 +761,12 @@
379 761 );
380 762 }
381 763
382 764 if($keep_original_folder_structure) {
383 - $object_key = ltrim($upload_path . '/' . dirname( $media_path ) . '/' . $prefix . $file_name, '/');
765 + $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
384 766 } else {
385 767 if(isset($year_month) && $year_month) {
386 - $year_month_prefix = self::get_year_month_from_file_path($media_path);
768 + $year_month_prefix = self::get_year_month_from_file_path($relative_source_path);
387 769 if($year_month_prefix) {
388 770 $upload_path.= '/'.$year_month_prefix;
389 771 } else {
390 772 $upload_path.= '/'.date("Y/m");
@@ -393,9 +775,9 @@
393 775
394 776 $object_key = ltrim($upload_path.'/'.$prefix.$file_name, '/');
395 777 }
396 778
397 - return apply_filters( 'wpmcs_object_key', $object_key, $media_path, $prefix );
779 + return apply_filters( 'wpmcs_object_key', $object_key, $relative_source_path, $prefix );
398 780 }
399 781
400 782
401 783 /**
@@ -589,6 +971,240 @@
589 971 }
590 972
591 973 return true;
592 974 }
975 +
976 +
977 + /**
978 + * Remove query strings of services.
979 + *
980 + * @param string $content
981 + * @param string $base_url Optional base URL that must exist within URL for Amazon query strings to be removed.
982 + *
983 + * @return string
984 + */
985 + public static function remove_query_strings( $content, $base_url = '' ) {
986 + $pattern = '\?[^\s"<\?]*(?:X-Amz-Algorithm|AWSAccessKeyId|Key-Pair-Id|GoogleAccessId)=[^\s"<\?]+';
987 + $group = 0;
988 +
989 + if ( ! is_string( $content ) ) {
990 + return $content;
991 + }
992 +
993 + if ( ! empty( $base_url ) ) {
994 + $pattern = preg_quote( $base_url, '/' ) . '[^\s"<\?]+(' . $pattern . ')';
995 + $group = 1;
996 + }
997 + if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches ) || ! isset( $matches[ $group ] ) ) {
998 + // No query strings found, return
999 + return $content;
1000 + }
1001 +
1002 + $matches = array_unique( $matches[ $group ] );
1003 +
1004 + foreach ( $matches as $match ) {
1005 + $content = str_replace( $match, '', $content );
1006 + }
1007 + return $content;
1008 + }
1009 +
1010 + /**
1011 + * Maybe unserialize data, but not if an object.
1012 + *
1013 + * @param mixed $data
1014 + *
1015 + * @return mixed
1016 + */
1017 + public static function maybe_unserialize( $data ) {
1018 + if ( is_serialized( $data ) ) {
1019 + return @unserialize( $data, array( 'allowed_classes' => false ) ); // @phpcs:ignore
1020 + }
1021 +
1022 + return $data;
1023 + }
1024 +
1025 +
1026 + /**
1027 + * Serialize data if needed.
1028 + *
1029 + * @param mixed $data
1030 + * @return mixed
1031 + */
1032 + public static function maybe_serialize( $data ) {
1033 + if ( is_array( $data ) || is_object( $data ) ) {
1034 + return serialize( $data );
1035 + }
1036 +
1037 + // If it's not an array or object, don't serialize. If it is already serialized, return as is.
1038 + if ( is_serialized( $data ) ) {
1039 + return $data;
1040 + }
1041 +
1042 + return $data;
1043 + }
1044 +
1045 +
1046 + /**
1047 + * Validate JSON
1048 + */
1049 + public static function is_json( $string ) {
1050 + json_decode( $string );
1051 + return ( json_last_error() == JSON_ERROR_NONE );
1052 + }
1053 +
1054 + /**
1055 + * Check whether a specific class::method exists in the current call stack.
1056 + *
1057 + * Useful for detecting callers like WooCommerce image regeneration
1058 + * without hard dependencies.
1059 + *
1060 + * @since 1.3.7
1061 + * @param string $class Fully qualified class name.
1062 + * @param string|null $function Method name (optional).
1063 + * @param int $depth Backtrace depth limit.
1064 + *
1065 + * @return bool
1066 + */
1067 + public static function is_called_from(
1068 + string $class,
1069 + ?string $function = null,
1070 + int $depth = 15
1071 + ) : bool {
1072 +
1073 + $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, $depth );
1074 +
1075 + foreach ( $trace as $frame ) {
1076 +
1077 + if ( empty( $frame['class'] ) ) {
1078 + continue;
1079 + }
1080 +
1081 + if ( $frame['class'] !== $class ) {
1082 + continue;
1083 + }
1084 +
1085 + // If function not specified, class match is enough
1086 + if ( $function === null ) {
1087 + return true;
1088 + }
1089 +
1090 + if ( isset( $frame['function'] ) && $frame['function'] === $function ) {
1091 + return true;
1092 + }
1093 + }
1094 +
1095 + return false;
1096 + }
1097 +
1098 +
1099 + /**
1100 + * Is this an AJAX process?
1101 + *
1102 + * @return bool
1103 + */
1104 + public static function is_ajax() {
1105 + if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
1106 + return true;
1107 + }
1108 +
1109 + return false;
1110 + }
1111 +
1112 + /**
1113 + * Helper function for filtering super globals. Easily testable.
1114 + *
1115 + * @param string $variable
1116 + * @param int $type
1117 + * @param int $filter
1118 + * @param mixed $options
1119 + *
1120 + * @return mixed
1121 + */
1122 + public static function filter_input( $variable, $type = INPUT_GET, $filter = FILTER_DEFAULT, $options = array() ) {
1123 + return filter_input( $type, $variable, $filter, $options );
1124 + }
1125 +
1126 + /**
1127 + * Get license data safe for frontend exposure (no raw key).
1128 + *
1129 + * @return array
1130 + */
1131 + public static function get_safe_license_data() {
1132 + $data = get_option('wpmcs_pro_license', []);
1133 + if (empty($data)) {
1134 + return [];
1135 + }
1136 + $key = $data['license_key'] ?? '';
1137 + $masked = '';
1138 + if (!empty($key)) {
1139 + $parts = explode('-', $key);
1140 + if (count($parts) <= 2) {
1141 + $masked = str_repeat('*', strlen($key));
1142 + } else {
1143 + $first = $parts[0];
1144 + $last = end($parts);
1145 + $middle_count = count($parts) - 2;
1146 + $masked_middle = array_fill(0, $middle_count, '****');
1147 + $masked = $first . '-' . implode('-', $masked_middle) . '-' . $last;
1148 + }
1149 + }
1150 + return [
1151 + 'masked_key' => $masked,
1152 + 'status' => $data['status'] ?? '',
1153 + 'expiry' => $data['expiry'] ?? '',
1154 + 'is_expired' => $data['is_expired'] ?? false,
1155 + 'is_domain_activated' => $data['is_domain_activated'] ?? false,
1156 + 'can_activate' => $data['can_activate'] ?? false,
1157 + 'message' => $data['message'] ?? '',
1158 + 'last_checked' => $data['last_checked'] ?? 0,
1159 + ];
1160 + }
1161 +
1162 + /**
1163 + * Whether Pro is installed and currently licensed (active, domain-activated, not expired).
1164 + * Single source of truth for this check — must match the frontend's isLicenseValid()
1165 + * (app/src/helper/index.js) field-for-field so backend and frontend never disagree about
1166 + * whether ajax/mixed sync mode is actually usable.
1167 + * @since 1.3.13
1168 + * @return bool
1169 + */
1170 + public static function is_pro_licensed() {
1171 + if (!defined('WPMCS_PRO_VERSION')) {
1172 + return false;
1173 + }
1174 +
1175 + $license = self::get_safe_license_data();
1176 +
1177 + return ($license['status'] ?? '') === 'active'
1178 + && ($license['is_domain_activated'] ?? false) === true
1179 + && empty($license['is_expired']);
1180 + }
1181 +
1182 + // Cache-Control for newly uploaded objects; 1 month by default, custom duration is Pro-only, no-cache only when duration is explicitly 0.
1183 + // @since 1.4.0
1184 + public static function get_cache_control_header() {
1185 + $duration = 1;
1186 + $unit = 'months';
1187 +
1188 + if (self::is_pro_licensed() && self::get_settings('cache_control_enabled', false)) {
1189 + $duration = (int) self::get_settings('cache_control_duration', 1);
1190 + $unit = self::get_settings('cache_control_unit', 'months');
1191 + }
1192 +
1193 + if ($duration <= 0) {
1194 + return 'no-cache, no-store, must-revalidate';
1195 + }
1196 +
1197 + $unit_seconds = [
1198 + 'seconds' => 1,
1199 + 'minutes' => MINUTE_IN_SECONDS,
1200 + 'hours' => HOUR_IN_SECONDS,
1201 + 'days' => DAY_IN_SECONDS,
1202 + 'weeks' => WEEK_IN_SECONDS,
1203 + 'months' => MONTH_IN_SECONDS,
1204 + 'years' => YEAR_IN_SECONDS,
1205 + ];
1206 +
1207 + return 'public, max-age=' . ($duration * ($unit_seconds[$unit] ?? MONTH_IN_SECONDS));
1208 + }
593 1209
594 1210 }