diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 5a3d5e6bca6..1aea174e8ce 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -86,6 +86,7 @@ + diff --git a/assets/src/settings-page/index.js b/assets/src/settings-page/index.js index fed0c1dc93a..832bf3d9925 100644 --- a/assets/src/settings-page/index.js +++ b/assets/src/settings-page/index.js @@ -335,7 +335,18 @@ function Root( { appRoot } ) { > - + + { __( 'Sandboxing (Experimental)', 'amp' ) } + + ) } + hiddenTitle={ __( 'Sandboxing (Experimental)', 'amp' ) } + id="sandboxing" + initialOpen={ 'sandboxing' === focusedSection } + > + + - { __( 'Sandboxing (Experimental)', 'amp' ) } - - ) } - hiddenTitle={ __( 'Sandboxing (Experimental)', 'amp' ) } - id="sandboxing" - initialOpen={ 'sandboxing' === focusedSection } - > + <>

{ __( 'Try out a more flexible AMP by generating pages that use AMP components without requiring AMP validity! By selecting a sandboxing level, you are indicating the minimum degree of sanitization. For example, if you selected the loose level but have a page without any POST form and no custom scripts, it will still be served as valid AMP—the same as if you had selected the strict level.', 'amp' ) }

@@ -112,9 +88,6 @@ export function Sandboxing( { focusedSection } ) { ) } -
+ ); } -Sandboxing.propTypes = { - focusedSection: PropTypes.string, -}; diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index ce125f0dfc7..68a1ad21405 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -404,7 +404,7 @@ function amp_is_available() { } $message = sprintf( - /* translators: 1: amp_is_available() function, 2: amp_is_request() function, 3: is_amp_endpoint() function */ + /* translators: 1: amp_is_available function, 2: amp_is_request function, 3: is_amp_endpoint function */ __( '%1$s (or %2$s, formerly %3$s) was called too early and so it will not work properly.', 'amp' ), '`amp_is_available()`', '`amp_is_request()`', @@ -741,9 +741,14 @@ function amp_add_amphtml_link() { } $amp_url = amp_add_paired_endpoint( amp_get_current_url() ); + if ( $amp_url ) { - $amp_url = remove_query_arg( QueryVar::NOAMP, $amp_url ); - printf( '', esc_url( $amp_url ) ); + $amp_url = remove_query_arg( QueryVar::NOAMP, $amp_url ); + $sandboxing_level = amp_get_sandboxing_level(); + + if ( 0 === $sandboxing_level || 3 === $sandboxing_level ) { + printf( '', esc_url( $amp_url ) ); + } } } @@ -2163,3 +2168,21 @@ function amp_remove_paired_endpoint( $url ) { return $url; } } + +/** + * Determine sandboxing level if enabled. + * + * @since 2.3.1 + * + * @return int Following values are possible: + * 0: Sandbox is disabled. + * 1: Sandboxing level: Loose. + * 2: Sandboxing level: Moderate. + * 3: Sandboxing level: Strict. + */ +function amp_get_sandboxing_level() { + if ( ! AMP_Options_Manager::get_option( Option::SANDBOXING_ENABLED ) ) { + return 0; + } + return AMP_Options_Manager::get_option( Option::SANDBOXING_LEVEL ); +} diff --git a/includes/validation/class-amp-validation-manager.php b/includes/validation/class-amp-validation-manager.php index 090bd0283aa..f0c61aeef85 100644 --- a/includes/validation/class-amp-validation-manager.php +++ b/includes/validation/class-amp-validation-manager.php @@ -2032,6 +2032,8 @@ public static function finalize_validation( Document $dom ) { return true; } + $sandboxing_level = amp_get_sandboxing_level(); + /* * In AMP-first, documents with invalid AMP markup can still be served. The amp attribute will be omitted in * order to prevent GSC from complaining about a validation error already surfaced inside of WordPress. @@ -2041,8 +2043,10 @@ public static function finalize_validation( Document $dom ) { * Otherwise, if in Paired AMP then redirect to the non-AMP version if the current user isn't an user who * can manage validation error statuses (access developer tools) and change the AMP options for the template * mode. Such users should be able to see kept invalid markup on the AMP page even though it is invalid. + * + * Also, if sandboxing is not set to strict mode, then the page should be displayed to the user. */ - if ( amp_is_canonical() ) { + if ( amp_is_canonical() || ( 1 === $sandboxing_level || 2 === $sandboxing_level ) ) { return true; } diff --git a/src/MobileRedirection.php b/src/MobileRedirection.php index 89f23f29743..0ba02f9bc62 100644 --- a/src/MobileRedirection.php +++ b/src/MobileRedirection.php @@ -64,7 +64,15 @@ public function register() { add_filter( 'amp_default_options', [ $this, 'filter_default_options' ] ); add_filter( 'amp_options_updating', [ $this, 'sanitize_options' ], 10, 2 ); - if ( AMP_Options_Manager::get_option( Option::MOBILE_REDIRECT ) && ! amp_is_canonical() ) { + $is_mobile_redirect_enabled = AMP_Options_Manager::get_option( Option::MOBILE_REDIRECT ); + $sandboxing_level = amp_get_sandboxing_level(); + + // Add alternative link if mobile redirection is enabled or sandboxing level is set to loose or moderate. + if ( ! amp_is_canonical() && ( $is_mobile_redirect_enabled || ( 1 === $sandboxing_level || 2 === $sandboxing_level ) ) ) { + add_action( 'wp_head', [ $this, 'add_mobile_alternative_link' ] ); + } + + if ( $is_mobile_redirect_enabled && ! amp_is_canonical() ) { add_action( 'template_redirect', [ $this, 'redirect' ], PHP_INT_MAX ); // Enable AMP-to-AMP linking by default to avoid redirecting to AMP version when navigating. @@ -144,11 +152,9 @@ public function redirect() { } // Print the mobile switcher styles. - add_action( 'wp_head', [ $this, 'add_mobile_version_switcher_styles' ] ); - add_action( 'amp_post_template_head', [ $this, 'add_mobile_version_switcher_styles' ] ); // For legacy Reader mode theme. + $this->add_mobile_switcher_head_hooks(); if ( ! amp_is_request() ) { - add_action( 'wp_head', [ $this, 'add_mobile_alternative_link' ] ); if ( $js ) { // Add mobile redirection script. add_action( 'wp_head', [ $this, 'add_mobile_redirect_script' ], ~PHP_INT_MAX ); @@ -165,21 +171,43 @@ public function redirect() { } // Add a link to the footer to allow for navigation to the AMP version. - add_action( 'wp_footer', [ $this, 'add_mobile_version_switcher_link' ] ); + $this->add_mobile_switcher_footer_hooks(); } else { if ( ! $js && $this->is_redirection_disabled_via_cookie() ) { $this->set_mobile_redirection_disabled_cookie( false ); } - add_filter( 'amp_to_amp_linking_element_excluded', [ $this, 'filter_amp_to_amp_linking_element_excluded' ], 100, 2 ); - add_filter( 'amp_to_amp_linking_element_query_vars', [ $this, 'filter_amp_to_amp_linking_element_query_vars' ], 10, 2 ); + $this->add_a2a_linking_hooks(); // Add a link to the footer to allow for navigation to the non-AMP version. - add_action( 'wp_footer', [ $this, 'add_mobile_version_switcher_link' ] ); - add_action( 'amp_post_template_footer', [ $this, 'add_mobile_version_switcher_link' ] ); // For legacy Reader mode theme. + $this->add_mobile_switcher_footer_hooks(); } } + /** + * Add mobile version switcher head hooks. + */ + private function add_mobile_switcher_head_hooks() { + add_action( 'wp_head', [ $this, 'add_mobile_version_switcher_styles' ] ); + add_action( 'amp_post_template_head', [ $this, 'add_mobile_version_switcher_styles' ] ); // For legacy Reader mode theme. + } + + /** + * Add mobile version switcher footer hooks. + */ + private function add_mobile_switcher_footer_hooks() { + add_action( 'wp_footer', [ $this, 'add_mobile_version_switcher_link' ] ); + add_action( 'amp_post_template_footer', [ $this, 'add_mobile_version_switcher_link' ] ); // For legacy Reader mode theme. + } + + /** + * Add AMP-to-AMP linking hooks. + */ + private function add_a2a_linking_hooks() { + add_filter( 'amp_to_amp_linking_element_excluded', [ $this, 'filter_amp_to_amp_linking_element_excluded' ], 100, 2 ); + add_filter( 'amp_to_amp_linking_element_query_vars', [ $this, 'filter_amp_to_amp_linking_element_query_vars' ], 10, 2 ); + } + /** * Ensure that links/forms which point to ?noamp up-front are excluded from AMP-to-AMP linking. * @@ -481,10 +509,12 @@ private function sanitize_script_attributes( $attributes ) { * @link https://developers.google.com/search/mobile-sites/mobile-seo/separate-urls#annotation-in-the-html */ public function add_mobile_alternative_link() { - printf( - '', - esc_url( $this->get_current_amp_url() ) - ); + if ( amp_is_available() && ! amp_is_request() ) { + printf( + '', + esc_url( $this->get_current_amp_url() ) + ); + } } /** @@ -574,6 +604,10 @@ public function add_mobile_version_switcher_link() { */ $text = apply_filters( 'amp_mobile_version_switcher_link_text', $text ); + if ( empty( $text ) ) { + return; + } + $hide_switcher = ( // The switcher must always be shown in the AMP version to allow accessing the non-AMP version. ! $is_amp diff --git a/src/Sandboxing.php b/src/Sandboxing.php index a2ba1118582..0cc8505a6d5 100644 --- a/src/Sandboxing.php +++ b/src/Sandboxing.php @@ -134,19 +134,12 @@ public function sanitize_options( $options, $new_options ) { * Add hooks. */ public function add_hooks() { - // Limit to Standard mode for now. To support in Transitional/Reader we'd need to discontinue redirecting invalid - // AMP to non-AMP and omit the amphtml link (in which case it would only be relevant when mobile redirection is - // enabled). - if ( ! amp_is_canonical() ) { - return; - } + $sandboxing_level = amp_get_sandboxing_level(); - if ( ! AMP_Options_Manager::get_option( Option::SANDBOXING_ENABLED ) ) { + if ( 0 === $sandboxing_level ) { return; } - $sandboxing_level = AMP_Options_Manager::get_option( Option::SANDBOXING_LEVEL ); - // Opt-in to the new script sanitization logic in the script sanitizer. add_filter( 'amp_content_sanitizers', @@ -212,6 +205,10 @@ private function remove_required_amp_markup_if_not_used( Document $dom, $effecti return; } + if ( $dom->ampElements->length > 0 ) { + return; + } + $amp_scripts = $dom->xpath->query( '//script[ @custom-element or @custom-template ]' ); if ( $amp_scripts->length > 0 ) { return; diff --git a/tests/php/src/MobileRedirectionTest.php b/tests/php/src/MobileRedirectionTest.php index 8ccd3b27717..ec1eb45002d 100644 --- a/tests/php/src/MobileRedirectionTest.php +++ b/tests/php/src/MobileRedirectionTest.php @@ -115,6 +115,98 @@ public function test_register_enabled_but_standard_mode() { $this->assert_hooks_not_added( $instance ); } + /** + * Get data for test_add_mobile_alternate_link + * + * @return array + */ + public function get_add_mobile_alternate_link() { + return [ + 'mobile_redirection_enabled' => [ + [ + Option::MOBILE_REDIRECT => true, + ], + 10, + ], + 'mobile_redirection_enabled_in_canonical_mode' => [ + [ + Option::MOBILE_REDIRECT => true, + Option::THEME_SUPPORT => AMP_Theme_Support::STANDARD_MODE_SLUG, + ], + false, + ], + 'sandboxing_set_to_loose' => [ + [ + Option::MOBILE_REDIRECT => false, + Option::SANDBOXING_ENABLED => true, + Option::SANDBOXING_LEVEL => 1, + ], + 10, + ], + 'sandboxing_set_to_loose_in_canonical_mode' => [ + [ + Option::MOBILE_REDIRECT => false, + Option::SANDBOXING_ENABLED => true, + Option::SANDBOXING_LEVEL => 1, + Option::THEME_SUPPORT => AMP_Theme_Support::STANDARD_MODE_SLUG, + ], + false, + ], + 'sandboxing_set_to_moderate' => [ + [ + Option::MOBILE_REDIRECT => false, + Option::SANDBOXING_ENABLED => true, + Option::SANDBOXING_LEVEL => 2, + ], + 10, + ], + 'sandboxing_set_to_moderate_in_canonical_mode' => [ + [ + Option::MOBILE_REDIRECT => false, + Option::SANDBOXING_ENABLED => true, + Option::SANDBOXING_LEVEL => 2, + Option::THEME_SUPPORT => AMP_Theme_Support::STANDARD_MODE_SLUG, + ], + false, + ], + 'sandboxing_set_to_strict' => [ + [ + Option::MOBILE_REDIRECT => false, + Option::SANDBOXING_ENABLED => true, + Option::SANDBOXING_LEVEL => 3, + ], + false, + ], + 'sandboxing_and_mobile_redirection_disabled' => [ + [ + Option::MOBILE_REDIRECT => false, + Option::SANDBOXING_ENABLED => false, + ], + false, + ], + ]; + } + + /** + * @dataProvider get_add_mobile_alternate_link + * + * Test action which adds mobile alternative link to head if: + * - mobile redirection is enabled. + * - sandboxing level is set to Loose or Moderate. + * + * @covers ::register() + * + * @param array $options AMP options. + * @param bool|int $expected Expected result. + */ + public function test_add_mobile_alternate_link( $options, $expected ) { + AMP_Options_Manager::update_options( $options ); + + $this->instance->register(); + + $this->assertSame( $expected, has_action( 'wp_head', [ $this->instance, 'add_mobile_alternative_link' ] ) ); + } + /** * Assert the service hooks were not added. * @@ -273,7 +365,6 @@ public function test_redirect_not_amp_endpoint_with_client_side_redirection() { $this->assertTrue( amp_is_available() ); $this->instance->redirect(); $this->assertEquals( 10, has_action( 'wp_head', [ $this->instance, 'add_mobile_version_switcher_styles' ] ) ); - $this->assertEquals( 10, has_action( 'wp_head', [ $this->instance, 'add_mobile_alternative_link' ] ) ); $this->assertEquals( 10, has_action( 'wp_footer', [ $this->instance, 'add_mobile_version_switcher_link' ] ) ); } @@ -360,6 +451,26 @@ public function test_redirect_on_transitional_and_available_and_server_side_on_a $this->assertEquals( 10, has_action( 'amp_post_template_footer', [ $this->instance, 'add_mobile_version_switcher_link' ] ) ); } + /** + * @covers ::add_mobile_switcher_head_hooks() + * @covers ::add_mobile_switcher_footer_hooks() + * @covers ::add_a2a_linking_hooks() + */ + public function test_add_mobile_switcher_hooks() { + $this->call_private_method( $this->instance, 'add_mobile_switcher_head_hooks' ); + $this->assertEquals( 10, has_action( 'wp_head', [ $this->instance, 'add_mobile_version_switcher_styles' ] ) ); + $this->assertEquals( 10, has_action( 'amp_post_template_head', [ $this->instance, 'add_mobile_version_switcher_styles' ] ) ); + + $this->call_private_method( $this->instance, 'add_mobile_switcher_footer_hooks' ); + $this->assertEquals( 10, has_action( 'wp_footer', [ $this->instance, 'add_mobile_version_switcher_link' ] ) ); + $this->assertEquals( 10, has_action( 'amp_post_template_footer', [ $this->instance, 'add_mobile_version_switcher_link' ] ) ); + + $this->call_private_method( $this->instance, 'add_a2a_linking_hooks' ); + $this->assertEquals( 0, has_filter( 'amp_to_amp_linking_enabled', '__return_true' ) ); + $this->assertEquals( 100, has_filter( 'amp_to_amp_linking_element_excluded', [ $this->instance, 'filter_amp_to_amp_linking_element_excluded' ] ) ); + $this->assertEquals( 10, has_filter( 'amp_to_amp_linking_element_query_vars', [ $this->instance, 'filter_amp_to_amp_linking_element_query_vars' ] ) ); + } + /** @covers ::filter_amp_to_amp_linking_element_excluded() */ public function test_filter_amp_to_amp_linking_element_excluded() { $home_url_without_noamp = home_url( '/' ); @@ -554,10 +665,20 @@ public function test_add_noamp_mobile_query_var() { /** @covers ::add_mobile_alternative_link() */ public function test_add_mobile_alternative_link() { + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::STANDARD_MODE_SLUG ); + $this->go_to( '/' ); ob_start(); $this->instance->add_mobile_alternative_link(); $output = ob_get_clean(); + $this->assertTrue( amp_is_request() ); + $this->assertEmpty( $output ); + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + $this->go_to( '/' ); + ob_start(); + $this->instance->add_mobile_alternative_link(); + $output = ob_get_clean(); + $this->assertFalse( amp_is_request() ); $this->assertStringStartsWith( 'assertStringNotContainsString( ' + + + + + %s + +
+ +
+ + + ', + $body + ) + ); + + // Keep effective level to 1 for testing because 3 will bail early. + $this->call_private_method( $this->instance, 'remove_required_amp_markup_if_not_used', [ $dom, 1 ] ); + + $expressions = [ + '//link[ @rel = "preconnect" and @href = "https://cdn.ampproject.org" ]', + '//style[ @amp-runtime ]', + '//script[ @src = "https://cdn.ampproject.org/v0.mjs" ]', + '//script[ @src = "https://cdn.ampproject.org/v0.js" ]', + ]; + + if ( $expected_required_amp_markup ) { + $this->assertEquals( 1, $dom->ampElements->length ); + } else { + $this->assertEquals( 0, $dom->ampElements->length ); + } + + foreach ( $expressions as $expression ) { + $this->assertEquals( $expected_required_amp_markup ? 1 : 0, $dom->xpath->query( $expression )->length, $expression ); + } + } + /** * Register settings and set the current user. */ diff --git a/tests/php/test-amp-helper-functions.php b/tests/php/test-amp-helper-functions.php index aafa5a37801..4e21a562bde 100644 --- a/tests/php/test-amp-helper-functions.php +++ b/tests/php/test-amp-helper-functions.php @@ -1009,6 +1009,94 @@ public function test_amp_add_amphtml_link_transitional_mode( $data_provider ) { } } + /** + * Get data for testing amp_add_amphtml_link() in sandboxing mode. + * + * @return array + */ + public function get_sandboxing_mode_amphtml_urls() { + return [ + 'is_sandboxing_disabled' => [ + static function() { + return [ + home_url( '/' ), + amp_add_paired_endpoint( home_url( '/' ) ), + 0, + ]; + }, + ], + 'is_set_to_strict_sandboxing_level' => [ + static function() { + return [ + home_url( '/' ), + amp_add_paired_endpoint( home_url( '/' ) ), + 3, + ]; + }, + ], + 'is_set_to_moderate_sandboxing_level' => [ + static function() { + return [ + home_url( '/' ), + amp_add_paired_endpoint( home_url( '/' ) ), + 2, + ]; + }, + ], + 'is_set_to_loose_sandboxing_level' => [ + static function() { + return [ + home_url( '/' ), + amp_add_paired_endpoint( home_url( '/' ) ), + 1, + ]; + }, + ], + ]; + } + + /** + * @dataProvider get_sandboxing_mode_amphtml_urls() + * + * @covers ::amp_add_amphtml_link() + * + * @param callable $data_provider Provider. + */ + public function test_amp_add_amphtml_link_sandboxing_mode( $data_provider ) { + list( $canonical_url, $amphtml_url, $sandboxing_level ) = $data_provider(); + + AMP_Options_Manager::update_option( Option::THEME_SUPPORT, AMP_Theme_Support::TRANSITIONAL_MODE_SLUG ); + + if ( $sandboxing_level > 0 ) { + AMP_Options_Manager::update_option( Option::SANDBOXING_ENABLED, true ); + AMP_Options_Manager::update_option( Option::SANDBOXING_LEVEL, $sandboxing_level ); + } + + $this->accept_sanitization_by_default( false ); + AMP_Theme_Support::init(); + $this->assertFalse( amp_is_canonical() ); + + $get_amp_html_link = static function() { + return get_echo( 'amp_add_amphtml_link' ); + }; + + $assert_amphtml_link_present = function() use ( $amphtml_url, $get_amp_html_link, $sandboxing_level ) { + $sandboxing_level = amp_get_sandboxing_level(); + + if ( 0 === $sandboxing_level || 3 === $sandboxing_level ) { + $this->assertEquals( + sprintf( '', esc_url( $amphtml_url ) ), + $get_amp_html_link() + ); + } else { + $this->assertEmpty( $get_amp_html_link() ); + } + }; + + $this->go_to( $canonical_url ); + $assert_amphtml_link_present(); + } + /** * Test amp_is_available() and amp_is_request() functions. * @@ -2653,4 +2741,29 @@ public static function mock_site_icon( $site_icon ) { unset( $site_icon ); return self::MOCK_SITE_ICON; } + + /** + * Test get sandboxing level. + * + * @covers ::amp_get_sandboxing_level() + */ + public function test_amp_get_sandboxing_level() { + // If sandboxing is disabled, then the level should be 0. + $this->assertEquals( 0, amp_get_sandboxing_level() ); + + // Enable sandboxing. + AMP_Options_Manager::update_option( Option::SANDBOXING_ENABLED, true ); + + // Enable level 1 which is Loose. + AMP_Options_Manager::update_option( Option::SANDBOXING_LEVEL, 1 ); + $this->assertEquals( 1, amp_get_sandboxing_level() ); + + // Enable level 2 which is moderate. + AMP_Options_Manager::update_option( Option::SANDBOXING_LEVEL, 2 ); + $this->assertEquals( 2, amp_get_sandboxing_level() ); + + // Enable level 3 which is Strict. + AMP_Options_Manager::update_option( Option::SANDBOXING_LEVEL, 3 ); + $this->assertEquals( 3, amp_get_sandboxing_level() ); + } } diff --git a/tests/php/test-class-amp-theme-support.php b/tests/php/test-class-amp-theme-support.php index 22875129f4b..3870a22be84 100644 --- a/tests/php/test-class-amp-theme-support.php +++ b/tests/php/test-class-amp-theme-support.php @@ -2261,6 +2261,7 @@ public function test_prepare_response_doing_template_actions() { }; foreach ( [ 'wp_head', 'wp_footer', 'amp_post_template_head', 'amp_post_template_footer' ] as $action ) { $wp_actions = []; + wp(); wp_enqueue_scripts(); $input = '' . $get_do_action( $action ) . ''; $output = AMP_Theme_Support::prepare_response( $input );