diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index aff292192bc..253a6382fd9 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -22,6 +22,7 @@ 'admin.validation_counts' => \AmpProject\AmpWP\Admin\ValidationCounts::class, 'amp_slug_customization_watcher' => \AmpProject\AmpWP\AmpSlugCustomizationWatcher::class, 'background_task_deactivator' => \AmpProject\AmpWP\BackgroundTask\BackgroundTaskDeactivator::class, + 'block_uniqid_transformer' => \AmpProject\AmpWP\BlockUniqidTransformer::class, 'cli.command_namespace' => \AmpProject\AmpWP\CliCli\CommandNamespaceRegistration::class, 'cli.optimizer_command' => \AmpProject\AmpWP\CliCli\OptimizerCommand::class, 'cli.transformer_command' => \AmpProject\AmpWP\CliCli\TransformerCommand::class, diff --git a/includes/options/class-amp-options-manager.php b/includes/options/class-amp-options-manager.php index 857af7c4665..93ef7a5fca8 100644 --- a/includes/options/class-amp-options-manager.php +++ b/includes/options/class-amp-options-manager.php @@ -351,12 +351,6 @@ public static function validate_options( $new_options ) { } } - if ( array_key_exists( Option::DISABLE_CSS_TRANSIENT_CACHING, $new_options ) && true === $new_options[ Option::DISABLE_CSS_TRANSIENT_CACHING ] ) { - $options[ Option::DISABLE_CSS_TRANSIENT_CACHING ] = true; - } else { - unset( $options[ Option::DISABLE_CSS_TRANSIENT_CACHING ] ); - } - /** * Filter the options being updated, so services can handle the sanitization and validation of * their respective options. diff --git a/includes/sanitizers/class-amp-block-uniqid-sanitizer.php b/includes/sanitizers/class-amp-block-uniqid-sanitizer.php new file mode 100644 index 00000000000..9ce4692fbff --- /dev/null +++ b/includes/sanitizers/class-amp-block-uniqid-sanitizer.php @@ -0,0 +1,204 @@ +key_pattern = sprintf( + '/\b(?P%s)(?P[0-9a-f]{13})\b/', + implode( + '|', + self::KEY_PREFIXES + ) + ); + } + + /** + * Sanitize. + */ + public function sanitize() { + $elements = $this->dom->xpath->query( + sprintf( + '//*[ %s ]', + implode( + ' or ', + array_map( + static function ( $class_name_prefix ) { + return sprintf( + 'contains( @class, "%s" )', + $class_name_prefix + ); + }, + self::KEY_PREFIXES + ) + ) + ) + ); + + $replaced_count = 0; + foreach ( $elements as $element ) { + if ( $this->transform_element_with_class_attribute( $element ) ) { + $replaced_count++; + } + } + + if ( $replaced_count > 0 ) { + $this->transform_styles(); + } + } + + /** + * Transform element with class. + * + * @param Element $element Element. + */ + public function transform_element_with_class_attribute( Element $element ) { + $class_name = $element->getAttribute( Attribute::CLASS_ ); + + $count = 0; + + $new_class_name = preg_replace_callback( + $this->key_pattern, + function ( $matches ) { + $old_key = $matches[0]; + + if ( ! isset( $this->key_mapping[ $old_key ] ) ) { + $this->key_mapping[ $old_key ] = self::unique_id( $matches['prefix'] ); + } + $new_key = $this->key_mapping[ $old_key ]; + + if ( in_array( $matches['prefix'], [ 'wp-duotone-', 'wp-duotone-filter-' ], true ) ) { + $this->transform_duotone_filter( $old_key, $new_key ); + } + + return $new_key; + }, + $class_name, + -1, + $count + ); + if ( 0 === $count ) { + return false; + } else { + $element->setAttribute( Attribute::CLASS_, $new_class_name ); + return true; + } + } + + /** + * Transform duotone filter by updating its ID. + * + * @param string $old_key Old identifier. + * @param string $new_key New identifier. + * + * @return void + */ + public function transform_duotone_filter( $old_key, $new_key ) { + $svg_filter = $this->dom->getElementById( $old_key ); + if ( $svg_filter instanceof Element && Tag::FILTER === $svg_filter->tagName ) { + $svg_filter->setAttribute( Attribute::ID, $new_key ); + } + } + + /** + * Transform styles. + */ + public function transform_styles() { + $styles = $this->dom->xpath->query( + sprintf( + '//style[ %s ]', + implode( + ' or ', + array_map( + static function ( $key_prefix ) { + return sprintf( + 'contains( text(), "%s" )', + $key_prefix + ); + }, + self::KEY_PREFIXES + ) + ) + ) + ); + + foreach ( $styles as $style ) { + $style->textContent = str_replace( + array_keys( $this->key_mapping ), + array_values( $this->key_mapping ), + $style->textContent + ); + } + } + + /** + * Gets unique ID. + * + * This is a polyfill for WordPress <5.0.3. + * + * @see wp_unique_id() + * + * @param string $prefix Prefix for the returned ID. + * @return string Unique ID. + */ + private static function unique_id( $prefix = '' ) { + if ( function_exists( 'wp_unique_id' ) ) { + return wp_unique_id( $prefix ); + } else { + // @codeCoverageIgnoreStart + static $id_counter = 0; + return $prefix . (string) ++$id_counter; + // @codeCoverageIgnoreEnd + } + } +} diff --git a/src/AmpWpPlugin.php b/src/AmpWpPlugin.php index 89e98c66f28..ee3a0a736f3 100644 --- a/src/AmpWpPlugin.php +++ b/src/AmpWpPlugin.php @@ -86,6 +86,7 @@ final class AmpWpPlugin extends ServiceBasedPlugin { 'admin.amp_themes' => Admin\AmpThemes::class, 'amp_slug_customization_watcher' => AmpSlugCustomizationWatcher::class, 'background_task_deactivator' => BackgroundTaskDeactivator::class, + 'block_uniqid_transformer' => BlockUniqidTransformer::class, 'cli.command_namespace' => Cli\CommandNamespaceRegistration::class, 'cli.optimizer_command' => Cli\OptimizerCommand::class, 'cli.transformer_command' => Cli\TransformerCommand::class, diff --git a/src/BackgroundTask/MonitorCssTransientCaching.php b/src/BackgroundTask/MonitorCssTransientCaching.php index a2965aefe84..643a7658fca 100644 --- a/src/BackgroundTask/MonitorCssTransientCaching.php +++ b/src/BackgroundTask/MonitorCssTransientCaching.php @@ -8,6 +8,7 @@ namespace AmpProject\AmpWP\BackgroundTask; use AMP_Options_Manager; +use AmpProject\AmpWP\BlockUniqidTransformer; use AmpProject\AmpWP\Option; use DateTimeImmutable; use DateTimeInterface; @@ -54,6 +55,32 @@ final class MonitorCssTransientCaching extends RecurringBackgroundTask { */ const DEFAULT_SAMPLING_RANGE = 14; + /** + * @string + */ + const WP_VERSION = 'wp_version'; + + /** + * @string + */ + const GUTENBERG_VERSION = 'gutenberg_version'; + + /** + * @var BlockUniqidTransformer + */ + private $block_uniqid_transformer; + + /** + * Constructor. + * + * @param BackgroundTaskDeactivator $background_task_deactivator Deactivator. + * @param BlockUniqidTransformer $block_uniqid_transformer Transformer. + */ + public function __construct( BackgroundTaskDeactivator $background_task_deactivator, BlockUniqidTransformer $block_uniqid_transformer ) { + parent::__construct( $background_task_deactivator ); + $this->block_uniqid_transformer = $block_uniqid_transformer; + } + /** * Register the service with the system. * @@ -61,6 +88,7 @@ final class MonitorCssTransientCaching extends RecurringBackgroundTask { */ public function register() { add_action( 'amp_plugin_update', [ $this, 'handle_plugin_update' ] ); + add_filter( 'amp_options_updating', [ $this, 'sanitize_disabled_option' ], 10, 2 ); parent::register(); } @@ -133,15 +161,60 @@ public function process( ...$args ) { * * @return bool Whether transient caching of stylesheets is disabled. */ - private function is_css_transient_caching_disabled() { - return AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ); + public function is_css_transient_caching_disabled() { + return (bool) AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ); + } + + /** + * Enable transient caching of stylesheets. + */ + public function enable_css_transient_caching() { + AMP_Options_Manager::update_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ); } /** * Disable transient caching of stylesheets. */ - private function disable_css_transient_caching() { - AMP_Options_Manager::update_option( Option::DISABLE_CSS_TRANSIENT_CACHING, true ); + public function disable_css_transient_caching() { + AMP_Options_Manager::update_option( + Option::DISABLE_CSS_TRANSIENT_CACHING, + [ + self::WP_VERSION => get_bloginfo( 'version' ), + self::GUTENBERG_VERSION => defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null, + ] + ); + } + + /** + * Sanitize the option. + * + * @param array $options Existing options. + * @param array $new_options New options. + * @return array Sanitized options. + */ + public function sanitize_disabled_option( $options, $new_options ) { + $value = null; + + if ( array_key_exists( Option::DISABLE_CSS_TRANSIENT_CACHING, $new_options ) ) { + $unsanitized_value = $new_options[ Option::DISABLE_CSS_TRANSIENT_CACHING ]; + + if ( is_bool( $unsanitized_value ) ) { + $value = (bool) $unsanitized_value; + } elseif ( is_array( $unsanitized_value ) ) { + $value = []; + foreach ( wp_array_slice_assoc( $unsanitized_value, [ self::WP_VERSION, self::GUTENBERG_VERSION ] ) as $key => $version ) { + $value[ $key ] = preg_replace( '/[^a-z0-9_\-.]/', '', $version ); + } + } + } + + if ( empty( $value ) ) { + unset( $options[ Option::DISABLE_CSS_TRANSIENT_CACHING ] ); + } else { + $options[ Option::DISABLE_CSS_TRANSIENT_CACHING ] = $value; + } + + return $options; } /** @@ -164,9 +237,39 @@ public function query_css_transient_count() { * @param string $old_version Old version. */ public function handle_plugin_update( $old_version ) { - // Reset the disabling of the CSS caching subsystem when updating from versions 1.5.0 or 1.5.1. - if ( version_compare( $old_version, '1.5.0', '>=' ) && version_compare( $old_version, '1.5.2', '<' ) ) { - AMP_Options_Manager::update_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ); + // Note: We cannot use the is_css_transient_caching_disabled method because we need to get the underlying stored value. + $disabled = AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ); + if ( empty( $disabled ) ) { + return; + } + + // Obtain the version of WordPress and Gutenberg at which time the functionality was disabled, if available. + $wp_version = isset( $disabled[ self::WP_VERSION ] ) ? $disabled[ self::WP_VERSION ] : null; + $gutenberg_version = isset( $disabled[ self::GUTENBERG_VERSION ] ) ? $disabled[ self::GUTENBERG_VERSION ] : null; + + if ( + // Reset the disabling of the CSS caching subsystem when updating from versions 1.5.0 or 1.5.1. + ( + version_compare( $old_version, '1.5.0', '>=' ) + && + version_compare( $old_version, '1.5.2', '<' ) + ) + || + // Reset when it was disabled prior to the versions of WP/Gutenberg being captured, + // or if the captured versions were affected at the time of disabling. + ( + version_compare( strtok( $old_version, '-' ), '2.2.2', '<' ) + && + ( + ! is_array( $disabled ) + || + $this->block_uniqid_transformer->is_affected_gutenberg_version( $gutenberg_version ) + || + $this->block_uniqid_transformer->is_affected_wordpress_version( $wp_version ) + ) + ) + ) { + $this->enable_css_transient_caching(); } } diff --git a/src/BlockUniqidTransformer.php b/src/BlockUniqidTransformer.php new file mode 100644 index 00000000000..c547eed696a --- /dev/null +++ b/src/BlockUniqidTransformer.php @@ -0,0 +1,132 @@ +gutenberg_version = GUTENBERG_VERSION; + } + } + + /** + * Check whether the Gutenberg plugin is present and if its one of the affected versions. + * + * Elements was added in 10.7 via WordPress/gutenberg#31524 + * Layout was added in 11.2 via WordPress/gutenberg#33359 + * Duotone was added in 11.7 via WordPress/gutenberg#34667 + * `uniqid` has been replaced by `wp_unique_id` in 12.7 via WordPress/gutenberg#38891 + * + * @param string|null $version Gutenberg version to check. If null, current version is used. + * @return bool Whether affected Gutenberg version. + */ + public function is_affected_gutenberg_version( $version = null ) { + if ( empty( $version ) ) { + $version = $this->gutenberg_version; + } + + if ( empty( $version ) ) { + return false; + } + + return ( + version_compare( $version, '10.7', '>=' ) + && + version_compare( $version, '12.7', '<' ) + ); + } + + /** + * Check whether WordPress version is affected by the `uniqid` issue. + * + * The affected WordPress version is 5.9. However, the duotone filter was first + * introduced in WordPress 5.8 and it makes use of the `uniqid`, too. + * + * @todo Once the `uniqid` to `wp_unique_id` fix is backported to core, upper version boundary should be updated (it's set to 6.0 for now). + * + * @param string|null $version WordPress core version to check. If null, current version is used. + * @return bool Whether affected WP version. + */ + public function is_affected_wordpress_version( $version = null ) { + if ( empty( $version ) ) { + $version = get_bloginfo( 'version' ); + } + return ( + version_compare( $version, '5.8', '>=' ) + && + version_compare( $version, '6.0', '<' ) + ); + } + + /** + * Check whether the transformer is necessary. + * + * @return bool Whether the conditional object is needed. + */ + public function is_necessary() { + return ( + $this->is_affected_gutenberg_version() + || + $this->is_affected_wordpress_version() + ); + } + + /** + * Register the service with the system. + * + * @return void + */ + public function register() { + if ( ! $this->is_necessary() ) { + return; + } + + add_filter( + 'amp_content_sanitizers', + static function ( $sanitizers ) { + $sanitizers = array_merge( + [ AMP_Block_Uniqid_Sanitizer::class => [] ], + $sanitizers + ); + return $sanitizers; + } + ); + } +} diff --git a/tests/php/src/BackgroundTask/MonitorCssTransientCachingTest.php b/tests/php/src/BackgroundTask/MonitorCssTransientCachingTest.php index 98e3928447f..64531a49ab2 100644 --- a/tests/php/src/BackgroundTask/MonitorCssTransientCachingTest.php +++ b/tests/php/src/BackgroundTask/MonitorCssTransientCachingTest.php @@ -6,14 +6,18 @@ namespace AmpProject\AmpWP\Tests\BackgroundTask; use AMP_Options_Manager; -use AmpProject\AmpWP\BackgroundTask\BackgroundTaskDeactivator; +use AMP_Style_Sanitizer; use AmpProject\AmpWP\BackgroundTask\MonitorCssTransientCaching; use AmpProject\AmpWP\Option; -use AmpProject\AmpWP\Tests\TestCase; +use AmpProject\AmpWP\Tests\DependencyInjectedTestCase; +use AmpProject\AmpWP\Tests\Helpers\PrivateAccess; +use AmpProject\Dom\Document; use DateTime; /** @coversDefaultClass \AmpProject\AmpWP\BackgroundTask\MonitorCssTransientCaching */ -class MonitorCssTransientCachingTest extends TestCase { +class MonitorCssTransientCachingTest extends DependencyInjectedTestCase { + + use PrivateAccess; /** * Whether external object cache is being used. @@ -22,6 +26,9 @@ class MonitorCssTransientCachingTest extends TestCase { */ private $was_wp_using_ext_object_cache; + /** @var string */ + private $original_wp_version; + /** * Set up the tests by clearing the list of scheduled events. */ @@ -30,6 +37,7 @@ public function setUp() { _set_cron_array( [] ); $this->was_wp_using_ext_object_cache = wp_using_ext_object_cache(); wp_using_ext_object_cache( false ); + $this->original_wp_version = $GLOBALS['wp_version']; } /** @@ -39,6 +47,33 @@ public function tearDown() { parent::tearDown(); _set_cron_array( [] ); wp_using_ext_object_cache( $this->was_wp_using_ext_object_cache ); + $GLOBALS['wp_version'] = $this->original_wp_version; + } + + /** + * @covers ::register() + */ + public function test_register() { + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); + $monitor->register(); + $this->assertEquals( 10, has_action( 'amp_plugin_update', [ $monitor, 'handle_plugin_update' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_options_updating', [ $monitor, 'sanitize_disabled_option' ] ) ); + } + + /** + * @covers ::get_interval() + */ + public function test_get_interval() { + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); + $this->assertIsString( $this->call_private_method( $monitor, 'get_interval' ) ); + } + + /** + * @covers ::get_event_name() + */ + public function test_get_event_name() { + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); + $this->assertIsString( $this->call_private_method( $monitor, 'get_event_name' ) ); } /** @@ -51,7 +86,7 @@ public function test_event_gets_scheduled_and_unscheduled() { wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); $this->assertFalse( wp_next_scheduled( MonitorCssTransientCaching::EVENT_NAME ) ); - $monitor = new MonitorCssTransientCaching( new BackgroundTaskDeactivator() ); + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); $monitor->schedule_event(); $timestamp = wp_next_scheduled( MonitorCssTransientCaching::EVENT_NAME ); @@ -66,10 +101,10 @@ public function test_event_gets_scheduled_and_unscheduled() { * * @covers ::process() */ - public function test_event_can_be_processed() { + public function test_process_causes_time_series_to_be_stored() { delete_option( MonitorCssTransientCaching::TIME_SERIES_OPTION_KEY ); - $monitor = new MonitorCssTransientCaching( new BackgroundTaskDeactivator() ); + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); $monitor->process(); $this->assertNotFalse( get_option( MonitorCssTransientCaching::TIME_SERIES_OPTION_KEY ) ); @@ -79,8 +114,14 @@ public function test_event_can_be_processed() { * Test whether transient caching is disabled once it hits the threshold. * * @covers ::process() + * @covers ::get_time_series() + * @covers ::get_sampling_range() + * @covers ::persist_time_series() + * @covers ::calculate_average() + * @covers ::get_threshold() + * @covers ::disable_css_transient_caching() */ - public function test_transient_caching_is_disabled() { + public function test_process_disables_transient_caching_once_threshold_is_reached() { delete_option( MonitorCssTransientCaching::TIME_SERIES_OPTION_KEY ); AMP_Options_Manager::update_option( Option::DISABLE_CSS_TRANSIENT_CACHING, false ); @@ -97,7 +138,7 @@ static function () { } ); - $monitor = new MonitorCssTransientCaching( new BackgroundTaskDeactivator() ); + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); // Moving average should be 0. $monitor->process( new DateTime( '2000-01-01' ), 5 ); @@ -137,6 +178,266 @@ static function () { ], get_option( MonitorCssTransientCaching::TIME_SERIES_OPTION_KEY ) ); - $this->assertTrue( AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING ) ); + + $expected = [ + MonitorCssTransientCaching::WP_VERSION => get_bloginfo( 'version' ), + MonitorCssTransientCaching::GUTENBERG_VERSION => defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null, + ]; + + $this->assertTrue( (bool) AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING ) ); + $this->assertEquals( $expected, AMP_Options_Manager::get_option( Option::DISABLE_CSS_TRANSIENT_CACHING ) ); + } + + /** + * @covers ::enable_css_transient_caching() + * @covers ::disable_css_transient_caching() + * @covers ::is_css_transient_caching_disabled() + */ + public function test_enable_disable_is_css_transient_caching_disabled() { + $monitor = $this->injector->make( MonitorCssTransientCaching::class ); + $this->assertFalse( $monitor->is_css_transient_caching_disabled() ); + $monitor->disable_css_transient_caching(); + $this->assertTrue( $monitor->is_css_transient_caching_disabled() ); + $monitor->enable_css_transient_caching(); + $this->assertFalse( $monitor->is_css_transient_caching_disabled() ); + } + + /** @return array */ + public function get_data_to_test_sanitize_disabled_option() { + return [ + 'true' => [ + true, + true, + ], + 'false' => [ + false, + null, + ], + 'number' => [ + 123, + null, + ], + 'bad_array' => [ + [ 1, 2, 3 ], + null, + ], + 'partial_good_array' => [ + [ + MonitorCssTransientCaching::WP_VERSION => '123-\o/', + MonitorCssTransientCaching::GUTENBERG_VERSION => '45.21