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 +391 -58 1.3.101.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,9 +53,9 @@
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 61 * Get Post Meta Data By Query
@@ -66,9 +66,9 @@
66 66 global $wpdb;
67 67 if(!(!empty($key) || $post_id)) return false;
68 68
69 69 if($db_query) {
70 - $meta_data = $wpdb->get_row( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=$post_id AND meta_key='$key'" );
70 + $meta_data = $wpdb->get_row( $wpdb->prepare( "SELECT meta_value FROM $wpdb->postmeta WHERE post_id=%d AND meta_key=%s", $post_id, $key ) );
71 71 if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
72 72 return false;
73 73 }
74 74 return $meta_data->meta_value;
@@ -86,9 +86,9 @@
86 86 global $wpdb;
87 87 if(!(!empty($key))) return false;
88 88
89 89 if($db_query) {
90 - $meta_data = $wpdb->get_row( "SELECT option_value FROM $wpdb->options WHERE option_name='$key'" );
90 + $meta_data = $wpdb->get_row( $wpdb->prepare( "SELECT option_value FROM $wpdb->options WHERE option_name=%s", $key ) );
91 91 if ($wpdb->last_error || null === $meta_data || !isset($meta_data)) {
92 92 return false;
93 93 }
94 94 return $meta_data->option_value;
@@ -121,10 +121,10 @@
121 121 * @since 1.0.0
122 122 * @return array|boolean|string|integer|float|double
123 123 */
124 124 public static function get_user_meta($post_id, $key, $default = false, $meta_name = false, $expire = false){
125 - $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, true );
126 - return $data == false ? $default : $data;
125 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, 'user' );
126 + return $data === false ? $default : $data;
127 127 }
128 128
129 129 /**
130 130 * Function To update Plugin Specific Wordpress user meta
@@ -131,9 +131,9 @@
131 131 * @since 1.0.0
132 132 * @return boolean
133 133 */
134 134 public static function update_user_meta($post_id, $key, $options, $meta_name = false, $expire = false){
135 - return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, true );
135 + return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, 'user' );
136 136 }
137 137
138 138 /**
139 139 * Function To delete Plugin Specific Wordpress user meta
@@ -140,16 +140,54 @@
140 140 * @since 1.0.0
141 141 * @return boolean
142 142 */
143 143 public static function delete_user_meta($post_id, $key, $meta_name = false){
144 - return Cache::delete_object_cache( $key, $post_id, $meta_name, true );
144 + return Cache::delete_object_cache( $key, $post_id, $meta_name, 'user' );
145 145 }
146 146
147 + /**
148 + * Function To get Plugin Specific meta via a caller-supplied storage backend
149 + * — for a meta table that isn't 'posts'/'users' and doesn't follow WP's
150 + * standard get_metadata() column conventions (e.g. BuddyBoss's groupmeta,
151 + * which uses its own get/update/delete functions internally).
152 + * @param array $backend ['get'=>callable, 'update'=>callable, 'delete'=>callable, 'prefix'=>string]
153 + * Each callable is shaped like get_post_meta($id,$key,true)/
154 + * update_post_meta($id,$key,$value)/delete_post_meta($id,$key).
155 + * @since 1.4.0.3
156 + * @return array|boolean|string|integer|float|double
157 + */
158 + public static function get_custom_meta($post_id, $key, $default = false, $meta_name = false, $expire = false, $backend = []){
159 + $data = Cache::get_object_cache( $key, $post_id, $meta_name, $expire, $backend );
160 + return $data === false ? $default : $data;
161 + }
147 162
148 163 /**
164 + * Function To update Plugin Specific meta via a caller-supplied storage backend. See get_custom_meta().
165 + * @since 1.4.0.3
166 + * @return boolean
167 + */
168 + public static function update_custom_meta($post_id, $key, $options, $meta_name = false, $expire = false, $backend = []){
169 + return Cache::set_object_cache( $key, $options, $post_id, $meta_name, $expire, $backend );
170 + }
171 +
172 + /**
173 + * Function To delete Plugin Specific meta via a caller-supplied storage backend. See get_custom_meta().
174 + * @since 1.4.0.3
175 + * @return boolean
176 + */
177 + public static function delete_custom_meta($post_id, $key, $meta_name = false, $backend = []){
178 + return Cache::delete_object_cache( $key, $post_id, $meta_name, $backend );
179 + }
180 +
181 +
182 + /**
149 183 * Clear meta from database
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.
150 188 */
151 - public static function clear_all_meta($meta_name = false, $meta_table = 'all') {
189 + public static function clear_all_meta($meta_name = false, $meta_table = 'all', $flush_cache = true) {
152 190 global $wpdb;
153 191
154 192 $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('META_KEY') : $meta_name;
155 193
@@ -167,33 +205,68 @@
167 205 if( in_array('usermeta', $meta_tables) ) {
168 206 // Clear user meta
169 207 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", $meta_name ) );
170 208 }
171 -
209 +
172 210 if( in_array('options', $meta_tables) ) {
173 211 // Clear options
174 212 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->options WHERE option_name = %s", $meta_name ) );
175 213 }
176 214
177 - // Clear object cache
178 - Cache::flush_object_cache();
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;
179 222 }
180 223
181 224 /**
182 225 * Clears all content meta from the database
183 226 *
184 - * @param string $meta_name Optional. The meta key to clear. Defaults to the constant CONTENT_META_KEY.
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.
185 229 */
186 - public static function clear_all_content_meta($meta_name = false) {
230 + public static function clear_all_content_meta($meta_name = false, $flush_cache = true) {
187 231 global $wpdb;
188 232 $meta_name = $meta_name == false || empty($meta_name) ? Schema::getConstant('CONTENT_META_KEY') : $meta_name;
189 233
190 234 $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->postmeta WHERE meta_key = %s", $meta_name ) );
191 - Cache::flush_object_cache();
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;
192 243 }
193 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 + }
194 252
253 + if (in_array('options', $meta_tables, true)) {
254 + wp_cache_delete('alloptions', 'options');
255 + wp_cache_delete($meta_name, 'options');
256 + }
195 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 +
196 269 /**
197 270 * Function To get Current credentials
198 271 * @since 1.0.0
199 272 * @return array|boolean|string|integer|float|double
@@ -200,21 +273,27 @@
200 273 */
201 274 public static function get_credentials($option='', $default=false, $masked_config = false){
202 275 $current_setttings = self::get_option('credentials',[], Schema::getConstant('GLOBAL_SETTINGS_KEY'));
203 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 +
204 289 if(isset($option) && !empty($option)){
205 290 if(isset($current_setttings[$option])) {
206 - if($masked_config && $option == 'config'){
207 - $current_setttings[$option] = self::mask_config($current_setttings[$option]);
208 - }
209 291 return $current_setttings[$option];
210 292 } else {
211 293 return $default;
212 294 }
213 295 } else {
214 - if($masked_config && isset($current_setttings['config'])){
215 - $current_setttings['config'] = self::mask_config($current_setttings['config']);
216 - }
217 296 return $current_setttings;
218 297 }
219 298 } else {
220 299 return $default;
@@ -221,8 +300,46 @@
221 300 }
222 301 }
223 302
224 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 + /**
225 342 * Mask Config
226 343 * @since 1.2.13
227 344 * @return array|boolean|string|integer|float|double
228 345 */
@@ -271,9 +388,10 @@
271 388 * @since 1.0.0
272 389 * @return array|boolean|string|integer|float|double
273 390 */
274 391 public static function get_status($option='', $default=false){
275 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
392 + $current_setttings = self::get_option('status', [], Schema::getConstant('STATUS_KEY'));
393 +
276 394 if(isset($current_setttings) && !empty($current_setttings)){
277 395 if(isset($option) && !empty($option)){
278 396 if(isset($current_setttings[$option])) {
279 397 return $current_setttings[$option];
@@ -293,15 +411,22 @@
293 411 * @since 1.0.0
294 412 *
295 413 */
296 414 public static function set_status($option='', $data=[]){
297 - $current_setttings = self::get_option('status',[], Schema::getConstant('STATUS_KEY'));
298 - if(isset($option) && !empty($option)){
299 - $current_setttings[$option] = $data;
300 - return self::update_option('status', $current_setttings, Schema::getConstant('STATUS_KEY'));
301 - } else {
415 + if(!isset($option) || empty($option)){
302 416 return false;
303 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);
304 429 }
305 430
306 431 /**
307 432 * Function To get Current Service
@@ -345,15 +470,29 @@
345 470 * @return boolean
346 471 */
347 472 public static function is_ok_to_serve($attachment_id = false, $check_id = true){
348 473 return (
349 - self::get_service() &&
474 + self::is_service_enabled() &&
350 475 self::get_settings('rewrite_url') &&
351 - ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
476 + ( $check_id ? isset($attachment_id) && !empty($attachment_id) : true )
352 477 );
353 478 }
354 479
355 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 + /**
356 495 * Function to check uploading media environment is ok
357 496 * @since 1.0.0
358 497 * @return boolean
359 498 */
@@ -358,9 +497,9 @@
358 497 * @return boolean
359 498 */
360 499 public static function is_ok_to_upload($attachment_id = false){
361 500 return (
362 - self::get_service() &&
501 + self::is_service_enabled() &&
363 502 self::get_settings('copy_to_bucket') &&
364 503 isset($attachment_id) && !empty($attachment_id)
365 504 );
366 505 }
@@ -365,14 +504,76 @@
365 504 );
366 505 }
367 506
368 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 + /**
369 570 * Function To check service is enabled
370 571 * @since 1.0.0
371 572 * @return array|boolean|string|integer|float|double
372 573 */
373 574 public static function is_service_enabled(){
374 - return !!self::get_service();
575 + return !!self::get_service() && self::has_valid_storage_credentials();
375 576 }
376 577
377 578 /**
378 579 * Check whether a file exist in a list of files
@@ -422,8 +623,14 @@
422 623
423 624 // Normalize slashes early
424 625 $file = str_replace( '\\', '/', $file );
425 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 +
426 633 /**
427 634 * -------------------------------------------------
428 635 * TYPE: SOURCE (WordPress local paths / URLs)
429 636 * -------------------------------------------------
@@ -437,19 +644,26 @@
437 644
438 645 $basedir = str_replace( '\\', '/', $uploads['basedir'] );
439 646 $baseurl = str_replace( '\\', '/', $uploads['baseurl'] );
440 647
441 - // If URL → extract path
442 - if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
648 + // If URL → extract path, then strip using baseurl's own path component —
649 + // once scheme+host are gone, comparing against the full $baseurl string
650 + // (which still has them) never matches.
651 + if ( $is_url ) {
443 652 $parsed = wp_parse_url( $file );
444 653 $file = $parsed['path'] ?? '';
445 - }
446 654
447 - // Strip WordPress upload root
448 - if ( 0 === strpos( $file, $basedir ) ) {
449 - $file = substr( $file, strlen( $basedir ) );
450 - } elseif ( 0 === strpos( $file, $baseurl ) ) {
451 - $file = substr( $file, strlen( $baseurl ) );
655 + $baseurl_path = (string) wp_parse_url( $baseurl, PHP_URL_PATH );
656 + if ( $baseurl_path !== '' && 0 === strpos( $file, $baseurl_path ) ) {
657 + $file = substr( $file, strlen( $baseurl_path ) );
658 + }
659 + } else {
660 + // Strip WordPress upload root
661 + if ( 0 === strpos( $file, $basedir ) ) {
662 + $file = substr( $file, strlen( $basedir ) );
663 + } elseif ( 0 === strpos( $file, $baseurl ) ) {
664 + $file = substr( $file, strlen( $baseurl ) );
665 + }
452 666 }
453 667 }
454 668
455 669 /**
@@ -459,9 +673,9 @@
459 673 */
460 674 elseif ( $type === 'key' ) {
461 675
462 676 // URL → extract path only
463 - if ( filter_var( $file, FILTER_VALIDATE_URL ) ) {
677 + if ( $is_url ) {
464 678 $parsed = wp_parse_url( $file );
465 679 $file = $parsed['path'] ?? '';
466 680 }
467 681
@@ -492,8 +706,15 @@
492 706 if ( $file === '' || substr( $file, -1 ) === '/' ) {
493 707 return false;
494 708 }
495 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 +
496 717 return apply_filters(
497 718 'wpmcs_get_relative_file_path_from_upload_directory',
498 719 $file,
499 720 $type
@@ -499,9 +720,35 @@
499 720 $type
500 721 );
501 722 }
502 723
724 + /**
725 + * Resolve a relative path (from get_attachment_source_path()) to an absolute path,
726 + * only if it genuinely stays within the uploads basedir — a defense-in-depth check
727 + * for callers about to file_exists()/read the result, alongside get_attachment_source_path()'s
728 + * own "..".
729 + * @since 1.4.1
730 + * @return string|false
731 + */
732 + public static function resolve_within_uploads( $relative_path ) {
733 + if ( empty( $relative_path ) || ! is_string( $relative_path ) ) {
734 + return false;
735 + }
503 736
737 + $basedir = trailingslashit( wp_get_upload_dir()['basedir'] );
738 + $absolute_path = $basedir . ltrim( $relative_path, '/' );
739 +
740 + $real_basedir = realpath( $basedir );
741 + $real_path = realpath( $absolute_path );
742 +
743 + if ( $real_basedir === false || $real_path === false || 0 !== strpos( $real_path, $real_basedir ) ) {
744 + return false;
745 + }
746 +
747 + return $absolute_path;
748 + }
749 +
750 +
504 751 /**
505 752 * Whether the file may be synced based on plugin extension settings only.
506 753 *
507 754 * Uses `extensions_exclude` to block listed extensions and optional `extensions_include` as an allow-list.
@@ -521,16 +768,26 @@
521 768 }
522 769
523 770 $ext = isset( $path_parts['extension'] ) ? strtolower( $path_parts['extension'] ) : '';
524 771
525 - $allowed = [];
526 - if ( isset( $settings['extensions_include'] ) && is_array( $settings['extensions_include'] ) ) {
527 - $allowed = array_map( 'strtolower', array_filter( $settings['extensions_include'], 'strlen' ) );
528 - }
772 + $allowed = [];
773 + $not_allowed = [];
529 774
530 - $not_allowed = [];
531 - if ( isset( $settings['extensions_exclude'] ) && is_array( $settings['extensions_exclude'] ) ) {
532 - $not_allowed = array_map( 'strtolower', array_filter( $settings['extensions_exclude'], 'strlen' ) );
775 + // Settings UI for these two fields is Pro-only; the values shouldn't apply without a license.
776 + if ( self::is_pro_licensed() ) {
777 + if (
778 + ! empty( $settings['extensions_include_enabled'] ) &&
779 + isset( $settings['extensions_include'] ) && is_array( $settings['extensions_include'] )
780 + ) {
781 + $allowed = array_map( 'strtolower', array_filter( $settings['extensions_include'], 'strlen' ) );
782 + }
783 +
784 + if (
785 + ! empty( $settings['extensions_exclude_enabled'] ) &&
786 + isset( $settings['extensions_exclude'] ) && is_array( $settings['extensions_exclude'] )
787 + ) {
788 + $not_allowed = array_map( 'strtolower', array_filter( $settings['extensions_exclude'], 'strlen' ) );
789 + }
533 790 }
534 791
535 792 if ( in_array( $ext, $not_allowed, true ) ) {
536 793 return false;
@@ -562,12 +819,22 @@
562 819 }
563 820
564 821
565 822 /**
823 + * Object key used for bucket permission checks.
824 + * Uses a .txt extension so CDN edge rules can serve the probe object.
825 + * @since 1.3.12
826 + * @return string
827 + */
828 + public static function get_permission_check_object_key() {
829 + return self::generate_object_key(WPMCS_TOKEN . '_dummy-object-for-bucket-permission-check.txt', '');
830 + }
831 +
832 + /**
566 833 * Generate Key for Objects
567 834 * @since 1.0.0
568 835 */
569 - public static function generate_object_key($relative_source_path, $prefix) {
836 + public static function generate_object_key($relative_source_path, $prefix, $is_private = false) {
570 837 $upload_path = '';
571 838 $enable_base_path = self::get_settings('enable_base_path', true);
572 839 $base_path = self::get_settings('base_path', 'wp-content/uploads');
573 840 $year_month = self::get_settings('year_month', true);
@@ -573,22 +840,40 @@
573 840 $year_month = self::get_settings('year_month', true);
574 841 $relative_source_path = ltrim( $relative_source_path, '/' );
575 842 $file_name = wp_basename( $relative_source_path );
576 843
577 - if(!$enable_base_path) { // If base path is not enabled
578 - $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 + }
579 872 }
580 873
581 874 $keep_original_folder_structure = apply_filters( 'wpmcs_keep_original_folder_structure', false );
582 875
583 - if(isset($base_path) && !empty($base_path)) {
584 - $upload_path.= preg_replace('~/+~', '/',
585 - str_replace('\\', '/',
586 - trim($base_path," \n\r\t\v\x00\/ ")
587 - )
588 - );
589 - }
590 -
591 876 if($keep_original_folder_structure) {
592 877 $object_key = ltrim($upload_path . '/' . dirname( $relative_source_path ) . '/' . $prefix . $file_name, '/');
593 878 } else {
594 879 if(isset($year_month) && $year_month) {
@@ -983,7 +1268,55 @@
983 1268 'can_activate' => $data['can_activate'] ?? false,
984 1269 'message' => $data['message'] ?? '',
985 1270 'last_checked' => $data['last_checked'] ?? 0,
986 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));
987 1320 }
988 1321
989 1322 }