diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 22ad912..122fc07 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -21,6 +21,7 @@ + @@ -29,7 +30,9 @@ - + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 129c6bb..74589a6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -78,12 +78,6 @@ parameters: count: 1 path: src/Admin/Settings/Api.php - - - message: '#^Parameter \#1 \$var of static method Perform\\Includes\\Helpers\:\:clean\(\) expects array\|string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Admin/Settings/Menu.php - - message: '#^Strict comparison using \!\=\= between ''0'' and mixed~\(0\|0\.0\|''''\|''0''\|array\{\}\|false\|null\) will always evaluate to true\.$#' identifier: notIdentical.alwaysTrue @@ -234,12 +228,6 @@ parameters: count: 2 path: src/Modules/Assets/AssetsManager.php - - - message: '#^Parameter \#1 \$text of function esc_attr expects string, int\|false given\.$#' - identifier: argument.type - count: 1 - path: src/Modules/Assets/AssetsManager.php - - message: '#^Property Perform\\Modules\\Assets\\AssetsManager\:\:\$loaded_assets type has no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -252,12 +240,6 @@ parameters: count: 1 path: src/Modules/Assets/AssetsManager.php - - - message: '#^Variable \$post_type might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: src/Modules/Assets/AssetsManager.php - - message: '#^Method Perform\\Modules\\Basic\\DisableEmbeds\:\:disable_embeds\(\) has no return type specified\.$#' identifier: missingType.return diff --git a/src/Admin/Settings/Menu.php b/src/Admin/Settings/Menu.php index db6eed4..510912e 100644 --- a/src/Admin/Settings/Menu.php +++ b/src/Admin/Settings/Menu.php @@ -97,13 +97,12 @@ public function save_settings() { // If the JS sent a JSON payload in `data`, decode it. Otherwise fall back to regular POST fields. $posted_data = []; if ( isset( $_POST['data'] ) ) { - $raw = wp_unslash( $_POST['data'] ); + $raw = wp_unslash( $_POST['data'] ); $decoded = json_decode( $raw, true ); if ( is_array( $decoded ) ) { - // Clean decoded values recursively - $posted_data = Helpers::clean( $decoded ); + $posted_data = $decoded; } else { - // Fallback: clean the entire $_POST array + // Fallback: clean the entire $_POST array. $posted_data = Helpers::clean( $_POST ); } } else { @@ -113,30 +112,35 @@ public function save_settings() { // Per-field sanitization based on field definitions provided by Helpers::get_settings_fields(). $sanitized_post = []; - foreach ( $posted_data as $key => $val ) { - // Skip known control keys early + foreach ( $posted_data as $key => $val ) { + // Skip known control keys early. if ( in_array( $key, [ 'perform_settings_barrier', '_wp_http_referer', 'action', 'nonce', 'data' ], true ) ) { continue; } - // If value is an array, recursively clean it (for list-type fields) - if ( is_array( $val ) ) { - $sanitized_post[ $key ] = Helpers::clean( $val ); - continue; - } - - $field_def = Helpers::find_field_by_id( $key ); - $raw_val = wp_unslash( $val ); + $field_def = Helpers::find_field_by_id( $key ); - // Keep existing secrets when UI sends masked placeholder. - if ( in_array( $key, ClientPayload::get_sensitive_keys(), true ) && ClientPayload::is_masked_secret( $raw_val ) ) { + if ( is_array( $val ) ) { + if ( $field_def && isset( $field_def['type'] ) && 'textarea' === $field_def['type'] ) { + $raw_val = implode( "\n", array_map( 'strval', $val ) ); + } else { + // If value is an array, recursively clean it for list-type fields. + $sanitized_post[ $key ] = Helpers::clean( $val ); continue; } + } else { + $raw_val = is_scalar( $val ) ? wp_unslash( $val ) : ''; + } - if ( $field_def && isset( $field_def['type'] ) ) { + // Keep existing secrets when UI sends masked placeholder. + if ( in_array( $key, ClientPayload::get_sensitive_keys(), true ) && ClientPayload::is_masked_secret( $raw_val ) ) { + continue; + } + + if ( $field_def && isset( $field_def['type'] ) ) { switch ( $field_def['type'] ) { case 'toggle': - // Normalize truthy values to 1, else 0 + // Normalize truthy values to 1, else 0. $sanitized_post[ $key ] = ! empty( $raw_val ) && '0' !== $raw_val ? 1 : 0; break; case 'textarea': @@ -146,15 +150,14 @@ public function save_settings() { $sanitized_post[ $key ] = esc_url_raw( $raw_val ); break; case 'select': - // Ensure value is one of allowed options when provided - $opts = $field_def['options'] ?? []; - $is_ok = false; + // Ensure value is one of allowed options when provided. + $opts = $field_def['options'] ?? []; + $is_ok = false; if ( is_array( $opts ) && ! empty( $opts ) ) { - // If associative array (value=>label) check keys, otherwise check values + // If associative array (value=>label) check keys, otherwise check values. $keys = array_keys( $opts ); $vals = array_values( $opts ); if ( array_diff_key( $opts, array_values( $opts ) ) ) { - // associative $is_ok = in_array( $raw_val, $keys, true ); } else { $is_ok = in_array( $raw_val, $vals, true ); @@ -169,30 +172,65 @@ public function save_settings() { $sanitized_post[ $key ] = sanitize_text_field( $raw_val ); } } else { - // No field definition found – fall back to a safe cleaning + // No field definition found; fall back to a safe cleaning. $sanitized_post[ $key ] = is_scalar( $raw_val ) ? sanitize_text_field( $raw_val ) : Helpers::clean( $raw_val ); } } - // Merge sanitized values with existing settings to preserve missing keys + // Merge sanitized values with existing settings to preserve missing keys. $new_settings = wp_parse_args( $sanitized_post, is_array( $settings ) ? $settings : [] ); - // Handle newline-separated lists - $new_settings['dns_prefetch'] = ! empty( $new_settings['dns_prefetch'] ) ? explode( "\n", $new_settings['dns_prefetch'] ) : ''; - $new_settings['preconnect'] = ! empty( $new_settings['preconnect'] ) ? explode( "\n", $new_settings['preconnect'] ) : ''; + // Handle newline-separated lists. + $new_settings['dns_prefetch'] = $this->normalize_multiline_setting( $new_settings['dns_prefetch'] ?? '' ); + $new_settings['preconnect'] = $this->normalize_multiline_setting( $new_settings['preconnect'] ?? '' ); $is_saved = update_option( 'perform_settings', $new_settings, false ); if ( $is_saved ) { - wp_send_json_success( [ - 'type' => 'success', - 'message' => esc_html__( 'Settings saved successfully.', 'perform' ), - ] ); + wp_send_json_success( + [ + 'type' => 'success', + 'message' => esc_html__( 'Settings saved successfully.', 'perform' ), + ] + ); + } else { + wp_send_json_error( + [ + 'type' => 'error', + 'message' => esc_html__( 'Unable to save the settings. Please try again.', 'perform' ), + ] + ); + } + } + + /** + * Normalize a textarea setting to the stored newline-list shape. + * + * @param mixed $value Textarea value. + * + * @return array|string + */ + private function normalize_multiline_setting( $value ) { + if ( is_array( $value ) ) { + $lines = $value; + } elseif ( is_scalar( $value ) && '' !== (string) $value ) { + $lines = preg_split( '/\r\n|\r|\n/', (string) $value ); } else { - wp_send_json_error( [ - 'type' => 'error', - 'message' => esc_html__( 'Unable to save the settings. Please try again.', 'perform' ), - ] ); + return ''; } + + $lines = array_filter( + array_map( + static function ( $line ) { + return sanitize_text_field( wp_unslash( $line ) ); + }, + $lines + ), + static function ( $line ) { + return '' !== $line; + } + ); + + return array_values( $lines ); } } diff --git a/src/Includes/Actions.php b/src/Includes/Actions.php index 71eaf35..e36e5dc 100644 --- a/src/Includes/Actions.php +++ b/src/Includes/Actions.php @@ -38,7 +38,7 @@ public function __construct() { public function enqueue_styles() { // Bailout, if can't display assets manager. - if ( ! Helpers::can_display_assets_manager() ) { + if ( ! current_user_can( 'manage_options' ) || ! Helpers::can_display_assets_manager() ) { return; } @@ -57,7 +57,7 @@ public function enqueue_styles() { public function enqueue_scripts() { // Bailout, if can't display assets manager. - if ( ! Helpers::can_display_assets_manager() ) { + if ( ! current_user_can( 'manage_options' ) || ! Helpers::can_display_assets_manager() ) { return; } diff --git a/src/Modules/Assets/AssetsManager.php b/src/Modules/Assets/AssetsManager.php index 6bf0cb6..fbd3e99 100644 --- a/src/Modules/Assets/AssetsManager.php +++ b/src/Modules/Assets/AssetsManager.php @@ -88,6 +88,60 @@ public function register(): void { } } + /** + * Get the current queried object ID as a stable integer. + * + * @return int + */ + private function get_current_object_id() { + $current_id = (int) get_queried_object_id(); + + if ( 0 >= $current_id ) { + $current_id = (int) get_the_ID(); + } + + return max( 0, $current_id ); + } + + /** + * Normalize stored object IDs while preserving the existing array contract. + * + * @param mixed $ids Stored IDs. + * + * @return array + */ + private function normalize_object_id_list( $ids ) { + if ( ! is_array( $ids ) ) { + return array(); + } + + $normalized = array(); + foreach ( $ids as $id ) { + if ( ! is_scalar( $id ) || ! is_numeric( $id ) ) { + continue; + } + + $id = (int) $id; + if ( 0 < $id ) { + $normalized[] = $id; + } + } + + return array_values( array_unique( $normalized ) ); + } + + /** + * Check whether a stored ID list contains the current object ID. + * + * @param int $current_id Current queried object ID. + * @param mixed $ids Stored IDs. + * + * @return bool + */ + private function has_current_object_id( $current_id, $ids ) { + return 0 < $current_id && in_array( (int) $current_id, $this->normalize_object_id_list( $ids ), true ); + } + /** * This function is used to load HTML of Assets Manager module. * @@ -654,7 +708,7 @@ public function disable_group_assets_html( $type, $handle ) { */ public function disable_assets_html( $type, $handle ) { $is_checked = ''; - $current_id = get_the_ID(); + $current_id = $this->get_current_object_id(); $radio_inputs = [ 'current' => esc_html__( 'Current URL', 'perform' ), 'everywhere' => esc_html__( 'Everywhere', 'perform' ), @@ -671,8 +725,7 @@ public function disable_assets_html( $type, $handle ) { if ( empty( $is_checked ) && - is_array( $is_disabled_key ) && - in_array( $current_id, $is_disabled_key, true ) + $this->has_current_object_id( $current_id, $is_disabled_key ) ) { $is_checked = " checked='checked'"; } else { @@ -731,11 +784,11 @@ public function print_assets_manager_status( $type, $handle ) { * @return void */ public function print_assets_manager_exceptions( $type, $handle ) { - $current_id = get_the_ID(); + $current_id = $this->get_current_object_id(); $selected_post_types = isset( $this->selected_options['enabled'][ $type ][ $handle ]['post_types'] ) ? $this->selected_options['enabled'][ $type ][ $handle ]['post_types'] : false; $is_selected = isset( $this->selected_options['disabled'][ $type ][ $handle ]['everywhere'] ) ? selected( $this->selected_options['disabled'][ $type ][ $handle ]['everywhere'], 1, false ) : ''; $current_exception = isset( $this->selected_options['enabled'][ $type ][ $handle ]['current'] ) ? $this->selected_options['enabled'][ $type ][ $handle ]['current'] : false; - $is_current_checked = ( is_array( $current_exception ) && in_array( $current_id, $current_exception, true ) ) ? ' checked="checked"' : ''; + $is_current_checked = $this->has_current_object_id( $current_id, $current_exception ) ? ' checked="checked"' : ''; ?>
>
@@ -745,7 +798,7 @@ public function print_assets_manager_exceptions( $type, $handle ) {
@@ -817,7 +870,7 @@ public function save_assets_manager_settings() { ! empty( $post_data['perform_assets_manager'] ) ) { - $current_id = get_queried_object_id(); + $current_id = $this->get_current_object_id(); $filters = [ 'js', 'css', 'plugins', 'themes' ]; $options = get_option( 'perform_assets_manager_options' ); $settings = get_option( 'perform_assets_manager_settings' ); @@ -857,35 +910,35 @@ public function save_assets_manager_settings() { 'disabled' === $status && ! empty( $value ) ) { - if ( 'everywhere' === $value ) { - $options['disabled'][ $type ][ $handle ]['everywhere'] = 1; + if ( 'everywhere' === $value ) { + $options['disabled'][ $type ][ $handle ]['everywhere'] = 1; - if ( ! empty( $options['disabled'][ $type ][ $handle ]['current'] ) ) { - unset( $options['disabled'][ $type ][ $handle ]['current'] ); - } - } elseif ( 'current' === $value ) { + if ( ! empty( $options['disabled'][ $type ][ $handle ]['current'] ) ) { + unset( $options['disabled'][ $type ][ $handle ]['current'] ); + } + } elseif ( 'current' === $value ) { - if ( isset( $options['disabled'][ $type ][ $handle ]['everywhere'] ) ) { - unset( $options['disabled'][ $type ][ $handle ]['everywhere'] ); - } + if ( isset( $options['disabled'][ $type ][ $handle ]['everywhere'] ) ) { + unset( $options['disabled'][ $type ][ $handle ]['everywhere'] ); + } - if ( ! isset( $options['disabled'][ $type ][ $handle ]['current'] ) || ! is_array( $options['disabled'][ $type ][ $handle ]['current'] ) ) { - $options['disabled'][ $type ][ $handle ]['current'] = []; - } + $options['disabled'][ $type ][ $handle ]['current'] = $this->normalize_object_id_list( $options['disabled'][ $type ][ $handle ]['current'] ?? array() ); - if ( ! in_array( $current_id, $options['disabled'][ $type ][ $handle ]['current'], true ) ) { - array_push( $options['disabled'][ $type ][ $handle ]['current'], $current_id ); + if ( 0 < $current_id && ! in_array( $current_id, $options['disabled'][ $type ][ $handle ]['current'], true ) ) { + array_push( $options['disabled'][ $type ][ $handle ]['current'], $current_id ); + } } - } } else { unset( $options['disabled'][ $type ][ $handle ]['everywhere'] ); if ( isset( $options['disabled'][ $type ][ $handle ]['current'] ) ) { + $options['disabled'][ $type ][ $handle ]['current'] = $this->normalize_object_id_list( $options['disabled'][ $type ][ $handle ]['current'] ); $current_key = array_search( $current_id, $options['disabled'][ $type ][ $handle ]['current'], true ); if ( false !== $current_key ) { unset( $options['disabled'][ $type ][ $handle ]['current'][ $current_key ] ); + $options['disabled'][ $type ][ $handle ]['current'] = array_values( $options['disabled'][ $type ][ $handle ]['current'] ); if ( empty( $options['disabled'][ $type ][ $handle ]['current'] ) ) { unset( $options['disabled'][ $type ][ $handle ]['current'] ); @@ -928,26 +981,27 @@ public function save_assets_manager_settings() { } $current_value = is_array( $value ) && array_key_exists( 'current', $value ) ? $value['current'] : ''; - $has_current_exception = '' !== (string) $current_value; + $current_value = is_scalar( $current_value ) && is_numeric( $current_value ) ? (int) $current_value : 0; + $has_current_exception = 0 < $current_value; if ( ! $group_disabled && 'disabled' === $status && $has_current_exception ) { - if ( ! isset( $options['enabled'][ $type ][ $handle ]['current'] ) || ! is_array( $options['enabled'][ $type ][ $handle ]['current'] ) ) { - $options['enabled'][ $type ][ $handle ]['current'] = []; - } + $options['enabled'][ $type ][ $handle ]['current'] = $this->normalize_object_id_list( $options['enabled'][ $type ][ $handle ]['current'] ?? array() ); if ( ! in_array( $current_value, $options['enabled'][ $type ][ $handle ]['current'], true ) ) { array_push( $options['enabled'][ $type ][ $handle ]['current'], $current_value ); } } else { if ( isset( $options['enabled'][ $type ][ $handle ]['current'] ) ) { + $options['enabled'][ $type ][ $handle ]['current'] = $this->normalize_object_id_list( $options['enabled'][ $type ][ $handle ]['current'] ); $current_key = array_search( $current_id, $options['enabled'][ $type ][ $handle ]['current'], true ); if ( false !== $current_key ) { unset( $options['enabled'][ $type ][ $handle ]['current'][ $current_key ] ); + $options['enabled'][ $type ][ $handle ]['current'] = array_values( $options['enabled'][ $type ][ $handle ]['current'] ); if ( empty( $options['enabled'][ $type ][ $handle ]['current'] ) ) { unset( $options['enabled'][ $type ][ $handle ]['current'] ); @@ -1050,7 +1104,7 @@ public function dequeue_assets( $src, $handle ) { // Load Assets Manager settings. $options = get_option( 'perform_assets_manager_options' ); - $current_id = get_queried_object_id(); + $current_id = $this->get_current_object_id(); $content_dirname = Helpers::get_content_dir_name(); // Get category + group from src. @@ -1076,11 +1130,11 @@ public function dequeue_assets( $src, $handle ) { ) || ( ! empty( $options['disabled'][ $type ][ $handle ]['current'] ) && - in_array( $current_id, $options['disabled'][ $type ][ $handle ]['current'], true ) + $this->has_current_object_id( $current_id, $options['disabled'][ $type ][ $handle ]['current'] ) ) ) { - if ( ! empty( $options['enabled'][ $type ][ $handle ]['current'] ) && in_array( $current_id, $options['enabled'][ $type ][ $handle ]['current'], true ) ) { + if ( ! empty( $options['enabled'][ $type ][ $handle ]['current'] ) && $this->has_current_object_id( $current_id, $options['enabled'][ $type ][ $handle ]['current'] ) ) { return $src; } diff --git a/src/Modules/Cache/PageCache.php b/src/Modules/Cache/PageCache.php index a71c15e..69c144d 100644 --- a/src/Modules/Cache/PageCache.php +++ b/src/Modules/Cache/PageCache.php @@ -89,8 +89,8 @@ public function should_load(): bool { * @return void */ public function register(): void { - $this->cache_dir = trailingslashit( WP_CONTENT_DIR ) . 'cache/perform/'; - $this->stats_store = new StatsStore(); + $this->cache_dir = trailingslashit( WP_CONTENT_DIR ) . 'cache/perform/'; + $this->stats_store = new StatsStore(); $this->url_normalizer = new UrlNormalizer(); add_filter( 'cron_schedules', [ $this, 'register_cron_schedule' ] ); @@ -182,22 +182,22 @@ public function maybe_serve_cache() { if ( $meta && is_string( $html ) && ! $is_regen_request ) { $now = time(); - if ( ! empty( $meta['expires'] ) && $now <= (int) $meta['expires'] ) { - $this->increment_stat( 'hits' ); - $this->send_cache_headers( 'HIT' ); - $this->flush_stats(); - echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - exit; - } + if ( ! empty( $meta['expires'] ) && $now <= (int) $meta['expires'] ) { + $this->increment_stat( 'hits' ); + $this->send_cache_headers( 'HIT' ); + $this->flush_stats(); + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } - if ( ! empty( $meta['swr_expires'] ) && $now <= (int) $meta['swr_expires'] ) { - $this->increment_stat( 'stale_hits' ); - $this->send_cache_headers( 'STALE' ); - $this->maybe_trigger_async_regeneration( $this->current_url ); - $this->flush_stats(); - echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - exit; - } + if ( ! empty( $meta['swr_expires'] ) && $now <= (int) $meta['swr_expires'] ) { + $this->increment_stat( 'stale_hits' ); + $this->send_cache_headers( 'STALE' ); + $this->maybe_trigger_async_regeneration( $this->current_url ); + $this->flush_stats(); + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } } // Miss path: acquire lock to avoid stampede. @@ -205,22 +205,26 @@ public function maybe_serve_cache() { $this->increment_stat( 'lock_waits' ); // If lock is held and stale exists, prefer stale over full uncached render. - if ( $meta && is_string( $html ) ) { - $this->increment_stat( 'stale_hits' ); - $this->send_cache_headers( 'STALE-LOCK' ); - $this->flush_stats(); - echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - exit; - } + if ( $meta && is_string( $html ) ) { + $this->increment_stat( 'stale_hits' ); + $this->send_cache_headers( 'STALE-LOCK' ); + $this->flush_stats(); + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } - $this->increment_stat( 'misses' ); - $this->record_top_miss( $this->current_url ); + $miss_increment = $this->increment_stat( 'misses' ); + if ( 0 < $miss_increment ) { + $this->record_top_miss( $this->current_url, $miss_increment ); + } return; } $this->should_write_cache = true; - $this->increment_stat( 'misses' ); - $this->record_top_miss( $this->current_url ); + $miss_increment = $this->increment_stat( 'misses' ); + if ( 0 < $miss_increment ) { + $this->record_top_miss( $this->current_url, $miss_increment ); + } } /** @@ -295,8 +299,8 @@ public function record_slow_uncached_request() { return; } - $url = '' !== $this->current_url ? $this->current_url : $this->get_normalized_request_url(); - $slow = $this->get_stat_map( 'slow_uncached' ); + $url = '' !== $this->current_url ? $this->current_url : $this->get_normalized_request_url(); + $slow = $this->get_stat_map( 'slow_uncached' ); $slow[ $url ] = round( $duration_ms ); arsort( $slow ); $slow = array_slice( $slow, 0, 30, true ); @@ -428,7 +432,7 @@ public function run_preload_batch() { ] ); - $processed++; + ++$processed; $this->increment_stat( 'preload_requests' ); } @@ -851,7 +855,7 @@ private function is_cacheable_request() { $path = isset( $_SERVER['REQUEST_URI'] ) ? wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : ''; if ( is_string( $path ) ) { - $path = untrailingslashit( strtolower( $path ) ); + $path = untrailingslashit( strtolower( $path ) ); $blocked_paths = [ '/cart', '/checkout', @@ -869,7 +873,7 @@ private function is_cacheable_request() { return false; } - $cookies = $_COOKIE; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $cookies = $_COOKIE; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $bypass_cookies = [ 'woocommerce_items_in_cart', 'woocommerce_cart_hash', @@ -1155,11 +1159,11 @@ private function write_file_atomically( $path, $contents ) { * * @param string $key Stat key. * - * @return void + * @return int Increment size applied to the counter. */ private function increment_stat( $key ) { $store = $this->get_stats_store(); - $store->increment( $key ); + return $store->increment( $key ); } /** @@ -1179,15 +1183,16 @@ private function set_stat_value( $key, $value ) { * Record top miss URLs. * * @param string $url URL. + * @param int $increment Count increment. * * @return void */ - private function record_top_miss( $url ) { + private function record_top_miss( $url, $increment = 1 ) { $map = $this->get_stat_map( 'top_misses' ); if ( ! isset( $map[ $url ] ) ) { $map[ $url ] = 0; } - $map[ $url ]++; + $map[ $url ] += max( 1, (int) $increment ); arsort( $map ); $map = array_slice( $map, 0, 50, true ); $this->set_stat_map( 'top_misses', $map ); diff --git a/src/Modules/Cache/StatsStore.php b/src/Modules/Cache/StatsStore.php index d1dc015..95e1cff 100644 --- a/src/Modules/Cache/StatsStore.php +++ b/src/Modules/Cache/StatsStore.php @@ -28,6 +28,34 @@ final class StatsStore { */ private $stats = []; + /** + * Stats snapshot loaded at request start. + * + * @var array + */ + private $base_stats = []; + + /** + * Buffered numeric counter increments. + * + * @var array + */ + private $increments = []; + + /** + * Buffered scalar values that should replace the stored value. + * + * @var array + */ + private $values = []; + + /** + * Buffered map stats. + * + * @var array> + */ + private $maps = []; + /** * Whether in-memory stats changed and need flush. * @@ -42,17 +70,38 @@ final class StatsStore { */ private $sampled_counter_keys = [ 'hits' => true, + 'misses' => true, 'stale_hits' => true, 'bypasses' => true, 'lock_waits' => true, ]; + /** + * Map stats whose values are additive counters. + * + * @var array + */ + private $additive_map_keys = [ + 'top_misses' => true, + ]; + + /** + * Maximum number of entries to persist for map stats. + * + * @var array + */ + private $map_limits = [ + 'top_misses' => 50, + 'slow_uncached' => 30, + ]; + /** * Constructor. */ public function __construct() { - $stats = get_option( $this->option_key, [] ); - $this->stats = is_array( $stats ) ? $stats : []; + $stats = get_option( $this->option_key, [] ); + $this->stats = is_array( $stats ) ? $stats : []; + $this->base_stats = $this->stats; } /** @@ -60,16 +109,19 @@ public function __construct() { * * @param string $key Stat key. * - * @return void + * @return int Increment size applied to the counter. */ public function increment( $key ) { $increment = $this->get_increment_sample_size( $key ); if ( 0 === $increment ) { - return; + return 0; } - $this->stats[ $key ] = isset( $this->stats[ $key ] ) ? ( (int) $this->stats[ $key ] + $increment ) : $increment; - $this->dirty = true; + $this->stats[ $key ] = isset( $this->stats[ $key ] ) ? ( (int) $this->stats[ $key ] + $increment ) : $increment; + $this->increments[ $key ] = isset( $this->increments[ $key ] ) ? ( $this->increments[ $key ] + $increment ) : $increment; + $this->dirty = true; + + return $increment; } /** @@ -103,8 +155,9 @@ private function get_increment_sample_size( $key ) { * @return void */ public function set_value( $key, $value ) { - $this->stats[ $key ] = $value; - $this->dirty = true; + $this->stats[ $key ] = $value; + $this->values[ $key ] = $value; + $this->dirty = true; } /** @@ -131,8 +184,11 @@ public function get_map( $key ) { * @return void */ public function set_map( $key, $value ) { + $value = $this->sort_and_limit_map( $key, $value ); + $this->stats[ $key ] = $value; - $this->dirty = true; + $this->maps[ $key ] = $value; + $this->dirty = true; } /** @@ -145,7 +201,89 @@ public function flush() { return; } - update_option( $this->option_key, $this->stats, false ); - $this->dirty = false; + $current = get_option( $this->option_key, [] ); + $current = is_array( $current ) ? $current : []; + + foreach ( $this->increments as $key => $increment ) { + $current[ $key ] = isset( $current[ $key ] ) ? ( (int) $current[ $key ] + $increment ) : $increment; + } + + foreach ( $this->values as $key => $value ) { + $current[ $key ] = $value; + } + + foreach ( $this->maps as $key => $map ) { + $current_map = $this->normalize_map( $current[ $key ] ?? [] ); + + if ( ! empty( $this->additive_map_keys[ $key ] ) ) { + $base_map = $this->normalize_map( $this->base_stats[ $key ] ?? [] ); + foreach ( $map as $map_key => $value ) { + $delta = $value - ( $base_map[ $map_key ] ?? 0 ); + if ( 0 >= $delta ) { + continue; + } + + $current_map[ $map_key ] = ( $current_map[ $map_key ] ?? 0 ) + $delta; + } + } else { + foreach ( $map as $map_key => $value ) { + $current_map[ $map_key ] = isset( $current_map[ $map_key ] ) ? max( $current_map[ $map_key ], $value ) : $value; + } + } + + $current[ $key ] = $this->sort_and_limit_map( $key, $current_map ); + } + + update_option( $this->option_key, $current, false ); + + $this->stats = $current; + $this->base_stats = $current; + $this->increments = []; + $this->values = []; + $this->maps = []; + $this->dirty = false; + } + + /** + * Normalize a map stat to numeric values. + * + * @param mixed $map Map value. + * + * @return array + */ + private function normalize_map( $map ) { + if ( ! is_array( $map ) ) { + return []; + } + + $normalized = []; + foreach ( $map as $key => $value ) { + if ( ! is_numeric( $value ) ) { + continue; + } + + $normalized[ (string) $key ] = $value + 0; + } + + return $normalized; + } + + /** + * Sort and limit a map stat. + * + * @param string $key Stat key. + * @param array $map Map value. + * + * @return array + */ + private function sort_and_limit_map( $key, array $map ) { + arsort( $map ); + + $limit = $this->map_limits[ $key ] ?? 0; + if ( 0 < $limit ) { + $map = array_slice( $map, 0, $limit, true ); + } + + return $map; } } diff --git a/src/Modules/MenuCache/MenuCache.php b/src/Modules/MenuCache/MenuCache.php index 0ffa870..4a3e663 100644 --- a/src/Modules/MenuCache/MenuCache.php +++ b/src/Modules/MenuCache/MenuCache.php @@ -92,7 +92,11 @@ class MenuCache implements ModuleInterface { */ public function should_load(): bool { $settings = Helpers::get_settings(); - return isset( $settings['enable_navigation_menu_cache'] ) && ! empty( $settings['enable_navigation_menu_cache'] ); + if ( empty( $settings['enable_navigation_menu_cache'] ) ) { + return false; + } + + return $this->supports_current_theme(); } /** @@ -106,6 +110,32 @@ public function register(): void { add_action( 'wp_update_nav_menu', [ $this, 'update_nav_menu_cache' ], 10, 2 ); } + /** + * Determine whether menu caching is appropriate for the active theme. + * + * Block themes render navigation through block output, so wp_nav_menu() + * caching is only enabled by default for classic/non-block themes. + * + * @return bool + */ + private function supports_current_theme(): bool { + $is_block_theme = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(); + $supported = ! $is_block_theme; + + return (bool) apply_filters( 'perform_menu_cache_supports_current_theme', $supported, $is_block_theme ); + } + + /** + * Determine whether the current request can safely use shared menu output. + * + * @return bool + */ + private function is_cacheable_request(): bool { + $cacheable = ! is_admin() && ! is_user_logged_in(); + + return (bool) apply_filters( 'perform_menu_cache_is_cacheable_request', $cacheable ); + } + /** * This function is used to output the cached navigation menu. * @@ -119,8 +149,8 @@ public function register(): void { */ public function cache_nav_menu_output( $output, $args ) { - // Validate input arguments. - if ( empty( $args ) || ! is_object( $args ) ) { + // Validate input arguments. + if ( ! $this->is_cacheable_request() || empty( $args ) || ! is_object( $args ) ) { return $output; } @@ -182,7 +212,7 @@ public function cache_nav_menu_output( $output, $args ) { public function cache_nav_menu( $nav_menu, $args ) { // Validate input arguments. - if ( empty( $args ) || ! is_object( $args ) || empty( $args->menu->term_id ) ) { + if ( ! $this->is_cacheable_request() || empty( $args ) || ! is_object( $args ) || empty( $args->menu->term_id ) ) { return $nav_menu; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0c975ec..e3ecaad 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,9 +8,9 @@ } if ( ! function_exists( 'get_option' ) ) { - function get_option( $name, $default = false ) { + function get_option( $name, $default_value = false ) { $store = isset( $GLOBALS['perform_test_options'] ) && is_array( $GLOBALS['perform_test_options'] ) ? $GLOBALS['perform_test_options'] : []; - return array_key_exists( $name, $store ) ? $store[ $name ] : $default; + return array_key_exists( $name, $store ) ? $store[ $name ] : $default_value; } } @@ -24,6 +24,74 @@ function update_option( $name, $value, $autoload = null ) { } } +if ( ! function_exists( 'add_action' ) ) { + function add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) { + return true; + } +} + +if ( ! function_exists( 'wp_enqueue_style' ) ) { + function wp_enqueue_style( $handle, $src = '', $deps = [], $ver = false, $media = 'all' ) { + $GLOBALS['perform_test_enqueued_styles'][] = $handle; + } +} + +if ( ! function_exists( 'wp_enqueue_script' ) ) { + function wp_enqueue_script( $handle, $src = '', $deps = [], $ver = false, $in_footer = false ) { + $GLOBALS['perform_test_enqueued_scripts'][] = $handle; + } +} + +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( $capability ) { + $capabilities = isset( $GLOBALS['perform_test_current_user_can'] ) && is_array( $GLOBALS['perform_test_current_user_can'] ) ? $GLOBALS['perform_test_current_user_can'] : []; + return ! empty( $capabilities[ $capability ] ); + } +} + +if ( ! function_exists( 'is_admin' ) ) { + function is_admin() { + return ! empty( $GLOBALS['perform_test_is_admin'] ); + } +} + +if ( ! function_exists( 'is_user_logged_in' ) ) { + function is_user_logged_in() { + return ! empty( $GLOBALS['perform_test_is_user_logged_in'] ); + } +} + +if ( ! function_exists( 'wp_is_block_theme' ) ) { + function wp_is_block_theme() { + return ! empty( $GLOBALS['perform_test_is_block_theme'] ); + } +} + +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( $hook_name, $value, ...$args ) { + $filters = isset( $GLOBALS['perform_test_filters'] ) && is_array( $GLOBALS['perform_test_filters'] ) ? $GLOBALS['perform_test_filters'] : []; + if ( ! array_key_exists( $hook_name, $filters ) ) { + return $value; + } + + if ( is_callable( $filters[ $hook_name ] ) ) { + return $filters[ $hook_name ]( $value, ...$args ); + } + + return $filters[ $hook_name ]; + } +} + +if ( ! function_exists( 'wp_rand' ) ) { + function wp_rand( $min = 0, $max = 0 ) { + if ( isset( $GLOBALS['perform_test_wp_rand'] ) ) { + return (int) $GLOBALS['perform_test_wp_rand']; + } + + return (int) $min; + } +} + if ( ! function_exists( 'wp_parse_url' ) ) { function wp_parse_url( $url, $component = -1 ) { return parse_url( $url, $component ); @@ -42,6 +110,12 @@ function sanitize_text_field( $value ) { } } +if ( ! function_exists( 'sanitize_textarea_field' ) ) { + function sanitize_textarea_field( $value ) { + return is_scalar( $value ) ? trim( (string) $value ) : $value; + } +} + if ( ! function_exists( 'wp_unslash' ) ) { function wp_unslash( $value ) { return $value; diff --git a/tests/unit-tests/tests-actions.php b/tests/unit-tests/tests-actions.php new file mode 100644 index 0000000..bc12ca4 --- /dev/null +++ b/tests/unit-tests/tests-actions.php @@ -0,0 +1,34 @@ + false, + ]; + $GLOBALS['perform_test_enqueued_scripts'] = []; + $GLOBALS['perform_test_enqueued_styles'] = []; + } + + protected function tearDown(): void { + unset( + $_GET['perform'], + $GLOBALS['perform_test_current_user_can'], + $GLOBALS['perform_test_enqueued_scripts'], + $GLOBALS['perform_test_enqueued_styles'] + ); + } + + public function test_assets_manager_assets_require_manage_options() { + $actions = new Actions(); + + $actions->enqueue_scripts(); + $actions->enqueue_styles(); + + $this->assertSame( [], $GLOBALS['perform_test_enqueued_scripts'] ); + $this->assertSame( [], $GLOBALS['perform_test_enqueued_styles'] ); + } +} diff --git a/tests/unit-tests/tests-assets-manager.php b/tests/unit-tests/tests-assets-manager.php new file mode 100644 index 0000000..2de9dbd --- /dev/null +++ b/tests/unit-tests/tests-assets-manager.php @@ -0,0 +1,23 @@ +setAccessible( true ); + + $this->assertSame( [ 42, 7 ], $method->invoke( $manager, [ '42', 42, '7', 'invalid', 0 ] ) ); + } + + public function test_current_object_id_lookup_matches_legacy_string_ids() { + $manager = new AssetsManager(); + $method = new ReflectionMethod( $manager, 'has_current_object_id' ); + $method->setAccessible( true ); + + $this->assertTrue( $method->invoke( $manager, 42, [ '42' ] ) ); + $this->assertFalse( $method->invoke( $manager, 11, [ '42' ] ) ); + } +} diff --git a/tests/unit-tests/tests-cache-stats-store.php b/tests/unit-tests/tests-cache-stats-store.php new file mode 100644 index 0000000..59faee2 --- /dev/null +++ b/tests/unit-tests/tests-cache-stats-store.php @@ -0,0 +1,65 @@ + 1, + ]; + unset( $GLOBALS['perform_test_wp_rand'] ); + } + + protected function tearDown(): void { + unset( $GLOBALS['perform_test_filters'], $GLOBALS['perform_test_wp_rand'] ); + } + + public function test_flush_merges_buffered_increments_with_latest_option() { + $GLOBALS['perform_test_options']['perform_cache_stats'] = [ + 'hits' => 10, + 'top_misses' => [ + '/a' => 2, + ], + ]; + + $store = new StatsStore(); + $store->increment( 'hits' ); + + $top_misses = $store->get_map( 'top_misses' ); + $top_misses['/a'] = 3; + $top_misses['/b'] = 1; + $store->set_map( 'top_misses', $top_misses ); + + $GLOBALS['perform_test_options']['perform_cache_stats'] = [ + 'hits' => 20, + 'top_misses' => [ + '/a' => 5, + '/c' => 4, + ], + ]; + + $store->flush(); + + $this->assertSame( 21, $GLOBALS['perform_test_options']['perform_cache_stats']['hits'] ); + $this->assertSame( 6, $GLOBALS['perform_test_options']['perform_cache_stats']['top_misses']['/a'] ); + $this->assertSame( 1, $GLOBALS['perform_test_options']['perform_cache_stats']['top_misses']['/b'] ); + $this->assertSame( 4, $GLOBALS['perform_test_options']['perform_cache_stats']['top_misses']['/c'] ); + } + + public function test_sampled_counter_does_not_dirty_when_sample_is_skipped() { + $GLOBALS['perform_test_options']['perform_cache_stats'] = [ + 'hits' => 10, + ]; + $GLOBALS['perform_test_filters']['perform_cache_stats_sample_rate'] = 20; + $GLOBALS['perform_test_wp_rand'] = 2; + + $store = new StatsStore(); + + $this->assertSame( 0, $store->increment( 'hits' ) ); + $store->flush(); + + $this->assertSame( 10, $GLOBALS['perform_test_options']['perform_cache_stats']['hits'] ); + } +} diff --git a/tests/unit-tests/tests-menu-cache.php b/tests/unit-tests/tests-menu-cache.php new file mode 100644 index 0000000..62c4bb1 --- /dev/null +++ b/tests/unit-tests/tests-menu-cache.php @@ -0,0 +1,38 @@ + [ + 'enable_navigation_menu_cache' => 1, + ], + ]; + unset( $GLOBALS['perform_test_filters'], $GLOBALS['perform_test_is_block_theme'] ); + } + + protected function tearDown(): void { + unset( $GLOBALS['perform_test_filters'], $GLOBALS['perform_test_is_block_theme'] ); + } + + public function test_menu_cache_does_not_load_for_block_themes_by_default() { + $GLOBALS['perform_test_is_block_theme'] = true; + + $menu_cache = new MenuCache(); + + $this->assertFalse( $menu_cache->should_load() ); + } + + public function test_menu_cache_filter_can_opt_in_hybrid_block_themes() { + $GLOBALS['perform_test_is_block_theme'] = true; + $GLOBALS['perform_test_filters'] = [ + 'perform_menu_cache_supports_current_theme' => true, + ]; + + $menu_cache = new MenuCache(); + + $this->assertTrue( $menu_cache->should_load() ); + } +} diff --git a/tests/unit-tests/tests-settings-menu.php b/tests/unit-tests/tests-settings-menu.php new file mode 100644 index 0000000..efef3a0 --- /dev/null +++ b/tests/unit-tests/tests-settings-menu.php @@ -0,0 +1,34 @@ +setAccessible( true ); + + $this->assertSame( + [ + 'https://fonts.example.com', + 'https://cdn.example.com', + ], + $method->invoke( $menu, "https://fonts.example.com\nhttps://cdn.example.com" ) + ); + } + + public function test_existing_multiline_arrays_keep_their_storage_shape() { + $menu = new Menu(); + $method = new ReflectionMethod( $menu, 'normalize_multiline_setting' ); + $method->setAccessible( true ); + + $this->assertSame( + [ + 'https://fonts.example.com', + 'https://cdn.example.com', + ], + $method->invoke( $menu, [ 'https://fonts.example.com', '', 'https://cdn.example.com' ] ) + ); + } +}