From f2959cd60d32c9c5bfddd6d5e0575098c27f8af9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 24 Nov 2024 20:29:41 -0800 Subject: [PATCH] Introduce od_store_url_metric_validity filter so Image Prioritizer can validate background-image URL --- plugins/image-prioritizer/helper.php | 63 ++++++++++++++++++- plugins/image-prioritizer/hooks.php | 1 + .../image-prioritizer/tests/test-helper.php | 2 +- .../storage/rest-api.php | 32 +++++++++- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/plugins/image-prioritizer/helper.php b/plugins/image-prioritizer/helper.php index 74fb9a3b06..dccbf82760 100644 --- a/plugins/image-prioritizer/helper.php +++ b/plugins/image-prioritizer/helper.php @@ -18,7 +18,7 @@ * @param string $optimization_detective_version Current version of the optimization detective plugin. */ function image_prioritizer_init( string $optimization_detective_version ): void { - $required_od_version = '0.7.0'; + $required_od_version = '0.9.0'; if ( ! version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '>=' ) ) { add_action( 'admin_notices', @@ -121,7 +121,6 @@ function image_prioritizer_filter_extension_module_urls( $extension_module_urls * @return array Additional properties. */ function image_prioritizer_add_element_item_schema_properties( array $additional_properties ): array { - // TODO: Validation of the URL. $additional_properties['lcpElementExternalBackgroundImage'] = array( 'type' => 'object', 'properties' => array( @@ -151,3 +150,63 @@ function image_prioritizer_add_element_item_schema_properties( array $additional ); return $additional_properties; } + +/** + * Validates that the provided background image URL is valid. + * + * @since n.e.x.t + * + * @param bool|WP_Error|mixed $validity Validity. Valid if true or a WP_Error without any errors, or invalid otherwise. + * @param OD_Strict_URL_Metric $url_metric URL Metric, already validated against the JSON Schema. + * @return bool|WP_Error Validity. Valid if true or a WP_Error without any errors, or invalid otherwise. + */ +function image_prioritizer_filter_store_url_metric_validity( $validity, OD_Strict_URL_Metric $url_metric ) { + if ( ! is_bool( $validity ) && ! ( $validity instanceof WP_Error ) ) { + $validity = (bool) $validity; + } + + $data = $url_metric->get( 'lcpElementExternalBackgroundImage' ); + if ( ! is_array( $data ) ) { + return $validity; + } + + $r = wp_safe_remote_head( + $data['url'], + array( + 'redirection' => 3, // Allow up to 3 redirects. + ) + ); + if ( $r instanceof WP_Error ) { + return new WP_Error( + WP_DEBUG ? $r->get_error_code() : 'head_request_failure', + __( 'HEAD request for background image URL failed.', 'image-prioritizer' ) . ( WP_DEBUG ? ' ' . $r->get_error_message() : '' ), + array( + 'code' => 500, + ) + ); + } + $response_code = wp_remote_retrieve_response_code( $r ); + if ( $response_code < 200 || $response_code >= 400 ) { + return new WP_Error( + 'background_image_response_not_ok', + __( 'HEAD request for background image URL did not return with a success status code.', 'image-prioritizer' ), + array( + 'code' => WP_DEBUG ? $response_code : 400, + ) + ); + } + + $content_type = wp_remote_retrieve_header( $r, 'Content-Type' ); + if ( ! is_string( $content_type ) || ! str_starts_with( $content_type, 'image/' ) ) { + return new WP_Error( + 'background_image_response_not_image', + __( 'HEAD request for background image URL did not return an image Content-Type.', 'image-prioritizer' ), + array( + 'code' => 400, + ) + ); + } + + // TODO: Check for the Content-Length and return invalid if it is gigantic? + return $validity; +} diff --git a/plugins/image-prioritizer/hooks.php b/plugins/image-prioritizer/hooks.php index 7587e9e67b..4a47f35647 100644 --- a/plugins/image-prioritizer/hooks.php +++ b/plugins/image-prioritizer/hooks.php @@ -13,3 +13,4 @@ add_action( 'od_init', 'image_prioritizer_init' ); add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ); add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ); +add_filter( 'od_store_url_metric_validity', 'image_prioritizer_filter_store_url_metric_validity', 10, 2 ); diff --git a/plugins/image-prioritizer/tests/test-helper.php b/plugins/image-prioritizer/tests/test-helper.php index ac67ac867e..867c70b0e5 100644 --- a/plugins/image-prioritizer/tests/test-helper.php +++ b/plugins/image-prioritizer/tests/test-helper.php @@ -20,7 +20,7 @@ public function data_provider_to_test_image_prioritizer_init(): array { 'expected' => false, ), 'with_new_version' => array( - 'version' => '0.7.0', + 'version' => '99.0.0', 'expected' => true, ), ); diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index fe622be468..7d8be56df0 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -84,7 +84,7 @@ function od_register_endpoint(): void { return new WP_Error( 'url_metric_storage_locked', __( 'URL Metric storage is presently locked for the current IP.', 'optimization-detective' ), - array( 'status' => 403 ) + array( 'status' => 403 ) // TODO: Consider 423 Locked status code. ); } return true; @@ -152,6 +152,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { $request->get_param( 'viewport' )['width'] ); } catch ( InvalidArgumentException $exception ) { + // Note: This should never happen because an exception only occurs if a viewport width is less than zero, and the JSON Schema enforces that the viewport.width have a minimum of zero. return new WP_Error( 'invalid_viewport_width', $exception->getMessage() ); } if ( $url_metric_group->is_complete() ) { @@ -197,6 +198,35 @@ function od_handle_rest_request( WP_REST_Request $request ) { ); } + /** + * Filters whether a URL Metric is valid for storage. + * + * This allows for custom validation constraints to be applied beyond what can be expressed in JSON Schema. This is + * also necessary because the 'validate_callback' key in a JSON Schema is not respected when gathering the REST API + * endpoint args via the {@see rest_get_endpoint_args_for_schema()} function. Besides this, the REST API doesn't + * support 'validate_callback' for any nested arguments in any case, meaning that custom constraints would be able + * to be applied to multidimensional objects, such as the items inside 'elements'. + * + * This filter only applies when storing a URL Metric via the REST API. It does not run when a stored URL Metric + * loaded from the od_url_metric post type. This means that validation logic enforced via this filter can be more + * expensive, such as doing filesystem checks or HTTP requests. + * + * @since n.e.x.t + * + * @param bool|WP_Error $validity Validity. Valid if true or a WP_Error without any errors, or invalid otherwise. + * @param OD_Strict_URL_Metric $url_metric URL Metric, already validated against the JSON Schema. + */ + $validity = apply_filters( 'od_store_url_metric_validity', true, $url_metric ); + if ( false === $validity || ( $validity instanceof WP_Error && $validity->has_errors() ) ) { + if ( false === $validity ) { + $validity = new WP_Error( 'invalid_url_metric', __( 'Validity of URL Metric was rejected by filter.', 'optimization-detective' ) ); + } + if ( ! isset( $validity->error_data['code'] ) ) { + $validity->error_data['code'] = 400; + } + return $validity; + } + // TODO: This should be changed from store_url_metric($slug, $url_metric) instead be update_post( $slug, $group_collection ). As it stands, store_url_metric() is duplicating logic here. $result = OD_URL_Metrics_Post_Type::store_url_metric( $request->get_param( 'slug' ),