diff --git a/src/AmpWpPlugin.php b/src/AmpWpPlugin.php index 5aa189ab7b6..0a466dee680 100644 --- a/src/AmpWpPlugin.php +++ b/src/AmpWpPlugin.php @@ -88,6 +88,7 @@ final class AmpWpPlugin extends ServiceBasedPlugin { 'url_validation_cron' => URLValidationCron::class, 'save_post_validation_event' => SavePostValidationEvent::class, 'background_task_deactivator' => BackgroundTaskDeactivator::class, + 'theme_entities_rest_controller' => Validation\ThemeEntitiesRESTController::class, ]; /** diff --git a/src/Validation/ThemeEntitiesRESTController.php b/src/Validation/ThemeEntitiesRESTController.php new file mode 100644 index 00000000000..b0154798800 --- /dev/null +++ b/src/Validation/ThemeEntitiesRESTController.php @@ -0,0 +1,258 @@ +namespace = 'amp/v1'; + $this->rest_base = self::REST_BASE; + $this->dev_tools_user_access = $dev_tools_user_access; + } + + /** + * Sets up the controller. + */ + public function register() { + if ( isset( $_GET['context'] ) && self::CONTEXT_THEME_DISABLED === $_GET['context'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated + $hooks = [ + 'pre_option_template', + 'option_template', + 'pre_option_stylesheet', + 'option_stylesheet', + ]; + + foreach ( $hooks as $hook ) { + add_filter( $hook, '__return_empty_string', 999 ); + } + } + + add_action( 'rest_api_init', [ $this, 'register_routes' ] ); + } + + /** + * Registers all routes for the controller. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_results' ], + 'args' => [ + 'context' => [ + 'default' => self::CONTEXT_THEME_ONLY, + 'description' => __( 'The request context.', 'amp' ), + 'enum' => [ + self::CONTEXT_THEME_DISABLED, + self::CONTEXT_THEME_ONLY, + ], + 'type' => 'string', + ], + ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + ], + 'schema' => $this->get_public_item_schema(), + ] + ); + } + + /** + * Checks whether the current user has permission to receive URLs. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has permission; WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( ! $this->dev_tools_user_access->is_user_enabled() ) { + return new WP_Error( + 'amp_rest_no_dev_tools', + __( 'Sorry, you do not have access to dev tools for the AMP plugin for WordPress.', 'amp' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + return true; + } + + /** + * Provides registered blocks, post types, taxonomies, and widgets. + * + * @return array + */ + private function get_entities() { + global $wp_widget_factory; + + return [ + 'blocks' => function_exists( 'get_dynamic_block_names' ) ? get_dynamic_block_names() : [], + 'post_types' => array_values( get_post_types() ), + 'taxonomies' => array_values( get_taxonomies() ), + 'widgets' => is_a( $wp_widget_factory, WP_Widget_Factory::class ) ? array_keys( $wp_widget_factory->widgets ) : [], + ]; + } + + /** + * Retrieves compatibility results. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_results( $request ) { + // As this request can be slow, we cache the result for the current theme and version. + $theme = wp_get_theme(); + $cache_key = md5( 'amp-theme-entities' . $theme->get( 'Name' ) . $theme->get( 'Version' ) ); + $cached_result = get_transient( $cache_key ); + + if ( $cached_result ) { + return rest_ensure_response( $cached_result ); + } + + // If the current request is for the theme-disabled context, the filters to disable the theme will have been added in ::register. + if ( self::CONTEXT_THEME_DISABLED === $request['context'] ) { + return rest_ensure_response( $this->get_entities() ); + } + + $entities_with_theme = $this->get_entities(); + + // Make a request to this endpoint with the theme disabled context. + $disabled_theme_request = wp_remote_get( + add_query_arg( + [ 'context' => self::CONTEXT_THEME_DISABLED ], + str_replace( 'https', 'http', rest_url( $this->namespace . '/' . $this->rest_base ) ) + ) + ); + + $entities_without_theme = json_decode( wp_remote_retrieve_body( $disabled_theme_request ), true ); + + // Collect only those entities that show up only when the theme is active. + $theme_entities = []; + foreach ( array_keys( $entities_with_theme ) as $key ) { + $theme_entities[ $key ] = array_values( array_diff( $entities_with_theme[ $key ], $entities_without_theme[ $key ] ) ); + } + + set_transient( $cache_key, $theme_entities, 30 * DAY_IN_SECONDS ); + + return rest_ensure_response( $theme_entities ); + } + + /** + * Retrieves the schema for plugin options provided by the endpoint. + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( ! $this->schema ) { + $this->schema = [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'amp-wp-theme-entities', + 'type' => 'object', + 'properties' => [ + 'blocks' => [ + 'items' => 'string', + 'type' => 'array', + ], + 'post_types' => [ + 'items' => 'string', + 'type' => 'array', + ], + 'taxonomies' => [ + 'items' => 'string', + 'type' => 'array', + ], + 'widgets' => [ + 'items' => 'string', + 'type' => 'array', + ], + ], + ]; + } + + return $this->schema; + } +} diff --git a/tests/php/src/Validation/ThemeEntitiesRESTControllerTest.php b/tests/php/src/Validation/ThemeEntitiesRESTControllerTest.php new file mode 100644 index 00000000000..e79714cce1d --- /dev/null +++ b/tests/php/src/Validation/ThemeEntitiesRESTControllerTest.php @@ -0,0 +1,237 @@ +dev_tools_user_access = new UserAccess(); + $this->instance = new ThemeEntitiesRESTController( $this->dev_tools_user_access ); + + add_filter( 'pre_http_request', [ $this, 'filter_remote_request' ], 10, 3 ); + do_action( 'rest_api_init' ); + } + + public function tearDown() { + parent::tearDown(); + + $theme = wp_get_theme(); + $cache_key = md5( 'amp-theme-entities' . $theme->get( 'Name' ) . $theme->get( 'Version' ) ); + delete_transient( $cache_key ); + } + + public function filter_remote_request( $preempt, $parsed_args, $url ) { + if ( false === strpos( $url, 'theme-entities&context=theme-disabled' ) ) { + return $preempt; + } + + return [ + 'body' => wp_json_encode( $this->call_private_method( $this->instance, 'get_entities' ) ), + ]; + + } + + /** @covers __construct() */ + public function test__construct() { + $this->assertInstanceOf( ThemeEntitiesRESTController::class, $this->instance ); + $this->assertInstanceOf( WP_REST_Controller::class, $this->instance ); + $this->assertInstanceOf( Conditional::class, $this->instance ); + $this->assertInstanceOf( Delayed::class, $this->instance ); + $this->assertInstanceOf( Service::class, $this->instance ); + $this->assertInstanceOf( Registerable::class, $this->instance ); + } + + /** @covers ::register() */ + public function test_register_with_default_context() { + $this->instance->register(); + + $this->assertEquals( 10, has_action( 'rest_api_init', [ $this->instance, 'register_routes' ] ) ); + + $this->assertFalse( has_filter( 'pre_option_template', '__return_empty_string' ) ); + $this->assertFalse( has_filter( 'option_template', '__return_empty_string' ) ); + $this->assertFalse( has_filter( 'pre_option_stylesheet', '__return_empty_string' ) ); + $this->assertFalse( has_filter( 'option_stylesheet', '__return_empty_string' ) ); + } + + /** @covers ::register() */ + public function test_register_with_theme_disabled_context() { + $_GET['context'] = ThemeEntitiesRESTController::CONTEXT_THEME_DISABLED; + $this->instance->register(); + + $this->assertEquals( 10, has_action( 'rest_api_init', [ $this->instance, 'register_routes' ] ) ); + + $this->assertEquals( 999, has_filter( 'pre_option_template', '__return_empty_string' ) ); + $this->assertEquals( 999, has_filter( 'option_template', '__return_empty_string' ) ); + $this->assertEquals( 999, has_filter( 'pre_option_stylesheet', '__return_empty_string' ) ); + $this->assertEquals( 999, has_filter( 'option_stylesheet', '__return_empty_string' ) ); + unset( $_GET['context'] ); // phpcs:ignore + } + + /** @covers ::register_routes */ + public function test_register_routes() { + $this->instance->register_routes(); + + $this->assertContains( 'amp/v1', rest_get_server()->get_namespaces() ); + $this->assertContains( '/amp/v1/theme-entities', array_keys( rest_get_server()->get_routes( 'amp/v1' ) ) ); + } + + /** + * @covers ::get_items_permissions_check() + */ + public function test_get_items_permissions_check() { + $this->assertWPError( $this->instance->get_items_permissions_check( new WP_REST_Request( 'GET', '/amp/v1/' . ThemeEntitiesRESTController::REST_BASE ) ) ); + + wp_set_current_user( $this->factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->assertWPError( $this->instance->get_items_permissions_check( new WP_REST_Request( 'GET', '/amp/v1/' . ThemeEntitiesRESTController::REST_BASE ) ) ); + + $this->dev_tools_user_access->set_user_enabled( wp_get_current_user(), true ); + + $this->assertTrue( $this->instance->get_items_permissions_check( new WP_REST_Request( 'GET', '/amp/v1/' . ThemeEntitiesRESTController::REST_BASE ) ) ); + } + + /** + * @covers ::get_results() + * @covers ::get_entities() + */ + public function test_get_results_with_theme_disabled_context() { + $request = new WP_REST_Request( 'GET', 'amp/v1/theme-entitites' ); + $request->set_url_params( [ 'context' => ThemeEntitiesRESTController::CONTEXT_THEME_DISABLED ] ); + + wp_widgets_init(); + $data = $this->instance->get_results( $request )->get_data(); + + $this->assertEquals( + array_keys( $data ), + [ + 'blocks', + 'post_types', + 'taxonomies', + 'widgets', + ] + ); + + if ( function_exists( 'get_dynamic_block_names' ) ) { + $this->assertNotEmpty( $data['blocks'] ); + } else { + $this->assertEmpty( $data['blocks'] ); + } + + $this->assertNotEmpty( $data['post_types'] ); + $this->assertNotEmpty( $data['taxonomies'] ); + $this->assertNotEmpty( $data['widgets'] ); + } + + /** + * @covers ::get_results() + * @covers ::get_entities() + */ + public function test_get_results_with_default_context() { + $request = new WP_REST_Request( 'GET', 'amp/v1/theme-entitites' ); + + $data = $this->instance->get_results( $request )->get_data(); + + $this->assertEquals( + array_keys( $data ), + [ + 'blocks', + 'post_types', + 'taxonomies', + 'widgets', + ] + ); + + $this->assertEmpty( $data['blocks'] ); + $this->assertEmpty( $data['post_types'] ); + $this->assertEmpty( $data['taxonomies'] ); + $this->assertEmpty( $data['widgets'] ); + } + + /** + * @covers ::get_results() + * @covers ::get_entities() + */ + public function test_get_results_cache() { + $request = new WP_REST_Request( 'GET', 'amp/v1/theme-entitites' ); + $this->instance->get_results( $request ); + + $theme = wp_get_theme(); + $cache_key = md5( 'amp-theme-entities' . $theme->get( 'Name' ) . $theme->get( 'Version' ) ); + + $this->assertEquals( + array_keys( get_transient( $cache_key ) ), + [ + 'blocks', + 'post_types', + 'taxonomies', + 'widgets', + ] + ); + } + + /** @covers ::get_item_schema() */ + public function test_get_item_schema() { + $schema = $this->instance->get_item_schema(); + + $this->assertEquals( + array_keys( $schema ), + [ + '$schema', + 'title', + 'type', + 'properties', + ] + ); + + $this->assertEquals( + array_keys( $schema['properties'] ), + [ + 'blocks', + 'post_types', + 'taxonomies', + 'widgets', + ] + ); + } +}