diff --git a/composer.json b/composer.json index c4e3d954a7..a0de99af67 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ }, "suggest": { "ext-imagick": "Required to use Modern Image Format's Dominant_Color_Image_Editor_Imagick class", - "ext-gd": "Required to use Modern Image Format's Dominant_Color_Image_Editor_GD class" + "ext-gd": "Required to use Modern Image Format's Dominant_Color_Image_Editor_GD class", + "ext-zlib": "Required for compression of URL Metric data submitted to the REST API for storage in Optimization Detective" }, "require-dev": { "phpcompatibility/php-compatibility": "^9.3", diff --git a/composer.lock b/composer.lock index f1cbf362b5..beb6cf9c37 100644 --- a/composer.lock +++ b/composer.lock @@ -157,16 +157,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "024473a478be9df5fdaca2c793f2232fe788e414" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", + "reference": "024473a478be9df5fdaca2c793f2232fe788e414", "shasum": "" }, "require": { @@ -205,7 +205,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" }, "funding": [ { @@ -213,7 +213,7 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-02-12T12:17:51+00:00" }, { "name": "phar-io/manifest", @@ -335,16 +335,16 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.7.1", + "version": "v6.7.2", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "83448e918bf06d1ed3d67ceb6a985fc266a02fd1" + "reference": "c04f96cb232fab12a3cbcccf5a47767f0665c3f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/83448e918bf06d1ed3d67ceb6a985fc266a02fd1", - "reference": "83448e918bf06d1ed3d67ceb6a985fc266a02fd1", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/c04f96cb232fab12a3cbcccf5a47767f0665c3f4", + "reference": "c04f96cb232fab12a3cbcccf5a47767f0665c3f4", "shasum": "" }, "require-dev": { @@ -377,9 +377,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.7.1" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.7.2" }, - "time": "2024-11-24T03:57:09+00:00" + "time": "2025-02-12T04:51:58+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -738,16 +738,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.14", + "version": "1.12.21", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38" + "reference": "14276fdef70575106a3392a4ed553c06a984df28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e73868f809e68fff33be961ad4946e2e43ec9e38", - "reference": "e73868f809e68fff33be961ad4946e2e43ec9e38", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/14276fdef70575106a3392a4ed553c06a984df28", + "reference": "14276fdef70575106a3392a4ed553c06a984df28", "shasum": "" }, "require": { @@ -792,7 +792,7 @@ "type": "github" } ], - "time": "2024-12-31T07:26:13+00:00" + "time": "2025-03-09T09:24:50+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -895,16 +895,16 @@ }, { "name": "phpstan/phpstan-strict-rules", - "version": "1.6.1", + "version": "1.6.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "daeec748b53de80a97498462513066834ec28f8b" + "reference": "b564ca479e7e735f750aaac4935af965572a7845" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/daeec748b53de80a97498462513066834ec28f8b", - "reference": "daeec748b53de80a97498462513066834ec28f8b", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/b564ca479e7e735f750aaac4935af965572a7845", + "reference": "b564ca479e7e735f750aaac4935af965572a7845", "shasum": "" }, "require": { @@ -938,9 +938,9 @@ "description": "Extra strict and opinionated rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.1" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.2" }, - "time": "2024-09-20T14:04:44+00:00" + "time": "2025-01-19T13:02:24+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2583,16 +2583,16 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": "^7.2 || ^8.0", "ext-json": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "7.2" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index b79e948cef..cd42fd583a 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -1,3 +1,5 @@ +// noinspection JSUnusedGlobalSymbols + /** * @typedef {import("web-vitals").LCPMetric} LCPMetric * @typedef {import("web-vitals").LCPMetricWithAttribution} LCPMetricWithAttribution @@ -214,7 +216,7 @@ function getCurrentTime() { /** * Recursively freezes an object to prevent mutation. * - * @param {Object} obj Object to recursively freeze. + * @param {Object} obj - Object to recursively freeze. */ function recursiveFreeze( obj ) { for ( const prop of Object.getOwnPropertyNames( obj ) ) { @@ -293,7 +295,7 @@ const reservedElementPropertyKeys = new Set( [ /** * Gets element data. * - * @param {string} xpath XPath. + * @param {string} xpath - XPath. * @return {ElementData|null} Element data, or null if no element for the XPath exists. */ function getElementData( xpath ) { @@ -309,8 +311,8 @@ function getElementData( xpath ) { /** * Extends element data. * - * @param {string} xpath XPath. - * @param {ExtendedElementData} properties Properties. + * @param {string} xpath - XPath. + * @param {ExtendedElementData} properties - Properties. */ function extendElementData( xpath, properties ) { if ( ! elementsByXPath.has( xpath ) ) { @@ -327,6 +329,23 @@ function extendElementData( xpath, properties ) { Object.assign( elementData, properties ); } +/** + * Compresses a (JSON) string using CompressionStream API. + * + * @param {string} jsonString - JSON string to compress. + * @return {Promise} Compressed data. + */ +async function compress( jsonString ) { + const encodedData = new TextEncoder().encode( jsonString ); + const compressedDataStream = new Blob( [ encodedData ] ) + .stream() + .pipeThrough( new CompressionStream( 'gzip' ) ); + const compressedDataBuffer = await new Response( + compressedDataStream + ).arrayBuffer(); + return new Blob( [ compressedDataBuffer ], { type: 'application/gzip' } ); +} + /** * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData * @typedef {{groups: Array<{url_metrics: Array}>}} CollectionDebugData @@ -335,23 +354,25 @@ function extendElementData( xpath, properties ) { /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * - * @param {Object} args Args. - * @param {string[]} args.extensionModuleUrls URLs for extension script modules to import. - * @param {number} args.minViewportAspectRatio Minimum aspect ratio allowed for the viewport. - * @param {number} args.maxViewportAspectRatio Maximum aspect ratio allowed for the viewport. - * @param {boolean} args.isDebug Whether to show debug messages. - * @param {string} args.restApiEndpoint URL for where to send the detection data. - * @param {string} [args.restApiNonce] Nonce for the REST API when the user is logged-in. - * @param {string} args.currentETag Current ETag. - * @param {string} args.currentUrl Current URL. - * @param {string} args.urlMetricSlug Slug for URL Metric. - * @param {number|null} args.cachePurgePostId Cache purge post ID. - * @param {string} args.urlMetricHMAC HMAC for URL Metric storage. - * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses URL Metric group statuses. - * @param {number} args.storageLockTTL The TTL (in seconds) for the URL Metric storage lock. - * @param {number} args.freshnessTTL The freshness age (TTL) for a given URL Metric. - * @param {string} args.webVitalsLibrarySrc The URL for the web-vitals library. - * @param {CollectionDebugData} [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode. + * @param {Object} args - Args. + * @param {string[]} args.extensionModuleUrls - URLs for extension script modules to import. + * @param {number} args.minViewportAspectRatio - Minimum aspect ratio allowed for the viewport. + * @param {number} args.maxViewportAspectRatio - Maximum aspect ratio allowed for the viewport. + * @param {boolean} args.isDebug - Whether to show debug messages. + * @param {string} args.restApiEndpoint - URL for where to send the detection data. + * @param {string} [args.restApiNonce] - Nonce for the REST API when the user is logged-in. + * @param {boolean} args.gzdecodeAvailable - Whether application/gzip can be sent to the REST API. + * @param {number} args.maxUrlMetricSize - Maximum size of the URL Metric to send. + * @param {string} args.currentETag - Current ETag. + * @param {string} args.currentUrl - Current URL. + * @param {string} args.urlMetricSlug - Slug for URL Metric. + * @param {number|null} args.cachePurgePostId - Cache purge post ID. + * @param {string} args.urlMetricHMAC - HMAC for URL Metric storage. + * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses - URL Metric group statuses. + * @param {number} args.storageLockTTL - The TTL (in seconds) for the URL Metric storage lock. + * @param {number} args.freshnessTTL - The freshness age (TTL) for a given URL Metric. + * @param {string} args.webVitalsLibrarySrc - The URL for the web-vitals library. + * @param {CollectionDebugData} [args.urlMetricGroupCollection] - URL Metric group collection, when in debug mode. */ export default async function detect( { minViewportAspectRatio, @@ -360,6 +381,8 @@ export default async function detect( { extensionModuleUrls, restApiEndpoint, restApiNonce, + gzdecodeAvailable, + maxUrlMetricSize, currentETag, currentUrl, urlMetricSlug, @@ -670,9 +693,7 @@ export default async function detect( { for ( const elementIntersection of elementIntersections ) { const xpath = breadcrumbedElementsMap.get( elementIntersection.target ); if ( ! xpath ) { - if ( isDebug ) { - error( 'Unable to look up XPath for element' ); - } + warn( 'Unable to look up XPath for element' ); continue; } @@ -795,10 +816,20 @@ export default async function detect( { const maxBodyLengthKiB = 64; const maxBodyLengthBytes = maxBodyLengthKiB * 1024; - // TODO: Consider adding replacer to reduce precision on numbers in DOMRect to reduce payload size. const jsonBody = JSON.stringify( urlMetric ); + if ( jsonBody.length > maxUrlMetricSize ) { + error( + `URL Metric is ${ jsonBody.length.toLocaleString() } bytes, exceeding the maximum size of ${ maxUrlMetricSize.toLocaleString() } bytes:`, + urlMetric + ); + return; + } + + const payloadBlob = gzdecodeAvailable + ? await compress( jsonBody ) + : new Blob( [ jsonBody ], { type: 'application/json' } ); const percentOfBudget = - ( jsonBody.length / ( maxBodyLengthKiB * 1000 ) ) * 100; + ( payloadBlob.size / ( maxBodyLengthKiB * 1000 ) ) * 100; /* * According to the fetch() spec: @@ -806,15 +837,13 @@ export default async function detect( { * This is what browsers also implement for navigator.sendBeacon(). Therefore, if the size of the JSON is greater * than the maximum, we should avoid even trying to send it. */ - if ( jsonBody.length > maxBodyLengthBytes ) { - if ( isDebug ) { - error( - `Unable to send URL Metric because it is ${ jsonBody.length.toLocaleString() } bytes, ${ Math.round( - percentOfBudget - ) }% of ${ maxBodyLengthKiB } KiB limit:`, - urlMetric - ); - } + if ( payloadBlob.size > maxBodyLengthBytes ) { + error( + `Unable to send URL Metric because it is ${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round( + percentOfBudget + ) }% of ${ maxBodyLengthKiB } KiB limit:`, + urlMetric + ); return; } @@ -830,7 +859,7 @@ export default async function detect( { ); } - const message = `Sending URL Metric (${ jsonBody.length.toLocaleString() } bytes, ${ Math.round( + const message = `Sending URL Metric (${ payloadBlob.size.toLocaleString() } bytes, ${ Math.round( percentOfBudget ) }% of ${ maxBodyLengthKiB } KiB limit):`; @@ -854,12 +883,7 @@ export default async function detect( { ); } url.searchParams.set( 'hmac', urlMetricHMAC ); - navigator.sendBeacon( - url, - new Blob( [ jsonBody ], { - type: 'application/json', - } ) - ); + navigator.sendBeacon( url, payloadBlob ); // Clean up. breadcrumbedElementsMap.clear(); diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 9ab3f6d0ae..a82040af63 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -140,6 +140,8 @@ static function ( OD_URL_Metric_Group $group ): array { 'storageLockTTL' => OD_Storage_Lock::get_ttl(), 'freshnessTTL' => od_get_url_metric_freshness_ttl(), 'webVitalsLibrarySrc' => $web_vitals_lib_src, + 'gzdecodeAvailable' => function_exists( 'gzdecode' ), + 'maxUrlMetricSize' => od_get_maximum_url_metric_size(), ); if ( is_user_logged_in() ) { $detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' ); diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 2e9c9176b0..d80681937e 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -24,4 +24,5 @@ add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' ); add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' ); add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 ); +add_filter( 'rest_pre_dispatch', 'od_decompress_rest_request_body', 10, 3 ); // @codeCoverageIgnoreEnd diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index fb67b6ae91..0ad854ce22 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -37,7 +37,7 @@ function od_get_url_metric_freshness_ttl(): int { if ( $freshness_ttl < 0 ) { _doing_it_wrong( - __FUNCTION__, + esc_html( "Filter: 'od_url_metric_freshness_ttl'" ), esc_html( sprintf( /* translators: %s is the TTL freshness */ @@ -382,15 +382,13 @@ function od_get_maximum_viewport_aspect_ratio(): float { * @return positive-int[] Breakpoint max widths, sorted in ascending order. */ function od_get_breakpoint_max_widths(): array { - $function_name = __FUNCTION__; - $breakpoint_max_widths = array_map( - static function ( $original_breakpoint ) use ( $function_name ): int { + static function ( $original_breakpoint ): int { $breakpoint = $original_breakpoint; if ( $breakpoint <= 0 ) { $breakpoint = 1; _doing_it_wrong( - esc_html( $function_name ), + esc_html( "Filter: 'od_breakpoint_max_widths'" ), esc_html( sprintf( /* translators: %s is the actual breakpoint max width */ @@ -447,7 +445,7 @@ function od_get_url_metrics_breakpoint_sample_size(): int { if ( $sample_size <= 0 ) { _doing_it_wrong( - __FUNCTION__, + esc_html( "Filter: 'od_url_metrics_breakpoint_sample_size'" ), esc_html( sprintf( /* translators: %s is the sample size */ @@ -462,3 +460,40 @@ function od_get_url_metrics_breakpoint_sample_size(): int { return $sample_size; } + +/** + * Gets the maximum allowed size in bytes for a URL Metric serialized to JSON. + * + * @since n.e.x.t + * @access private + * + * @return positive-int Maximum allowed byte size. + */ +function od_get_maximum_url_metric_size(): int { + /** + * Filters the maximum allowed size in bytes for a URL Metric serialized to JSON. + * + * The default value is 1 MB. + * + * @since n.e.x.t + * + * @param int $max_size Maximum allowed byte size. + * @return int Filtered maximum allowed byte size. + */ + $size = (int) apply_filters( 'od_maximum_url_metric_size', MB_IN_BYTES ); + if ( $size <= 0 ) { + _doing_it_wrong( + esc_html( "Filter: 'od_maximum_url_metric_size'" ), + esc_html( + sprintf( + /* translators: %s: size */ + __( 'Invalid size "%s". Must be greater than zero.', 'optimization-detective' ), + $size + ) + ), + 'Optimization Detective 1.0.0' + ); + $size = MB_IN_BYTES; + } + return $size; +} diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 2cd3b1711c..ceda7bd891 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -218,13 +218,8 @@ function od_handle_rest_request( WP_REST_Request $request ) { ); } - /* - * The limit for data sent via navigator.sendBeacon() is 64 KiB. This limit is checked in detect.js so that the - * request will not even be attempted if the payload is too large. This server-side restriction is added as a - * safeguard against clients sending possibly malicious payloads much larger than 64 KiB which should never be - * getting sent. - */ - $max_size = 64 * 1024; + // Limit JSON payload size to safeguard against clients sending possibly malicious payloads much larger than allowed. + $max_size = od_get_maximum_url_metric_size(); $content_length = strlen( (string) wp_json_encode( $url_metric ) ); if ( $content_length > $max_size ) { return new WP_Error( @@ -346,3 +341,62 @@ function od_trigger_page_cache_invalidation( int $cache_purge_post_id ): void { /** This action is documented in wp-includes/post.php. */ do_action( 'save_post', $post->ID, $post, /* $update */ true ); } + +/** + * Decompresses the REST API request body for the URL Metrics endpoint. + * + * @since n.e.x.t + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param mixed $result Response to replace the requested version with. Can be anything a normal endpoint can return, or null to not hijack the request. + * @param WP_REST_Server $server Server instance. + * @param WP_REST_Request $request Request used to generate the response. + * @return mixed Passed through $result if successful, or otherwise a WP_Error. + */ +function od_decompress_rest_request_body( $result, WP_REST_Server $server, WP_REST_Request $request ) { + if ( + $request->get_route() === '/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE && + 'application/gzip' === $request->get_header( 'Content-Type' ) && + function_exists( 'gzdecode' ) + ) { + $compressed_body = $request->get_body(); + + /* + * The limit for data sent via navigator.sendBeacon() is 64 KiB. This limit is checked in detect.js so that the + * request will not even be attempted if the payload is too large. This server-side restriction is added as a + * safeguard against clients sending possibly malicious payloads much larger than 64 KiB which should never be + * getting sent. + */ + $max_size = 64 * 1024; // 64 KB + $content_length = strlen( $compressed_body ); + if ( $content_length > $max_size ) { + return new WP_Error( + 'rest_content_too_large', + sprintf( + /* translators: 1: the size of the payload, 2: the maximum allowed payload size */ + __( 'Compressed JSON payload size is %1$s bytes which is larger than the maximum allowed size of %2$s bytes.', 'optimization-detective' ), + number_format_i18n( $content_length ), + number_format_i18n( $max_size ) + ), + array( 'status' => 413 ) + ); + } + + $decompressed_body = @gzdecode( $compressed_body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We need to suppress errors here. + + if ( false === $decompressed_body ) { + return new WP_Error( + 'rest_invalid_payload', + __( 'Unable to decompress the gzip payload.', 'optimization-detective' ), + array( 'status' => 400 ) + ); + } + + // Update the request so later handlers see the decompressed JSON. + $request->set_body( $decompressed_body ); + $request->set_header( 'Content-Type', 'application/json' ); + } + return $result; +} diff --git a/plugins/optimization-detective/tests/storage/test-data.php b/plugins/optimization-detective/tests/storage/test-data.php index 0d154a9d5a..df72483063 100644 --- a/plugins/optimization-detective/tests/storage/test-data.php +++ b/plugins/optimization-detective/tests/storage/test-data.php @@ -48,10 +48,10 @@ static function (): int { /** * Test bad od_get_url_metric_freshness_ttl(). * - * @expectedIncorrectUsage od_get_url_metric_freshness_ttl * @covers ::od_get_url_metric_freshness_ttl */ public function test_bad_od_get_url_metric_freshness_ttl(): void { + $this->setExpectedIncorrectUsage( 'Filter: 'od_url_metric_freshness_ttl'' ); add_filter( 'od_url_metric_freshness_ttl', static function (): int { @@ -728,13 +728,13 @@ public function data_provider_test_bad_od_get_breakpoint_max_widths(): array { * * @covers ::od_get_breakpoint_max_widths * - * @expectedIncorrectUsage od_get_breakpoint_max_widths * @dataProvider data_provider_test_bad_od_get_breakpoint_max_widths * * @param int[] $breakpoints Breakpoints. * @param int[] $expected Expected breakpoints. */ public function test_bad_od_get_breakpoint_max_widths( array $breakpoints, array $expected ): void { + $this->setExpectedIncorrectUsage( 'Filter: 'od_breakpoint_max_widths'' ); add_filter( 'od_breakpoint_max_widths', static function () use ( $breakpoints ): array { @@ -766,10 +766,10 @@ static function (): string { /** * Test bad od_get_url_metrics_breakpoint_sample_size(). * - * @expectedIncorrectUsage od_get_url_metrics_breakpoint_sample_size * @covers ::od_get_url_metrics_breakpoint_sample_size */ public function test_bad_od_get_url_metrics_breakpoint_sample_size(): void { + $this->setExpectedIncorrectUsage( 'Filter: 'od_url_metrics_breakpoint_sample_size'' ); add_filter( 'od_url_metrics_breakpoint_sample_size', static function (): int { diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index ab9a24e1c1..83ceccba02 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -75,6 +75,7 @@ static function ( array $properties ) use ( $property_name ): array { * @covers ::od_register_endpoint * @covers ::od_handle_rest_request * @covers ::od_trigger_page_cache_invalidation + * @covers ::od_decompress_rest_request_body * @covers OD_Strict_URL_Metric::set_additional_properties_to_false * @covers OD_URL_Metric_Store_Request_Context::__construct * @covers OD_URL_Metric_Store_Request_Context::__get @@ -348,16 +349,32 @@ public function data_provider_invalid_params(): array { 'params' => array_merge( $valid_params, array( - // Repeat the elements until the JSON will surpass 64 KiB. - 'elements' => array_fill( - 0, - 200, + // Fill the JSON with more than 64KB of incomprehensible data. + 'elements' => array( + array_merge( + $valid_element, + array( + 'xpath' => sprintf( '/HTML/BODY/DIV[@id=\'%s\']/*[1][self::DIV]', bin2hex( random_bytes( KB_IN_BYTES * 65 ) ) ), + ) + ), + ), + ) + ), + 'expected_status' => 413, + 'expected_code' => 'rest_content_too_large', + ), + 'invalid_decoded_json_body_content_length' => array( + 'params' => array_merge( + $valid_params, + array( + // Fill the JSON with more than 1MB of highly compressible data. + 'elements' => array( array_merge( $valid_element, array( - 'xpath' => '/HTML/BODY/DIV[@id=\'page\']/*[1][self::DIV]', + 'xpath' => sprintf( '/HTML/BODY/DIV[@id=\'%s\']/*[1][self::DIV]', str_repeat( 'A', MB_IN_BYTES ) ), ) - ) + ), ), ) ), @@ -457,6 +474,7 @@ public function data_provider_invalid_params(): array { * * @covers ::od_register_endpoint * @covers ::od_handle_rest_request + * @covers ::od_decompress_rest_request_body * @covers OD_Strict_URL_Metric::set_additional_properties_to_false * * @dataProvider data_provider_invalid_params @@ -589,6 +607,23 @@ public function test_rest_request_non_array_json_body(): void { $this->assertSame( 0, did_action( 'od_url_metric_stored' ) ); } + + /** + * Test invalid compressed JSON body. + * + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request + * @covers ::od_decompress_rest_request_body + */ + public function test_rest_request_invalid_compressed_json_body(): void { + $request = $this->create_request( $this->get_valid_params() ); + $request->set_body( 'Invalid compressed JSON body' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 'rest_invalid_payload', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 0, did_action( 'od_url_metric_stored' ) ); + } + /** * Test timestamp ignored. * @@ -855,6 +890,151 @@ public function test_od_trigger_page_cache_invalidation_invalid_post_id(): void $this->assertSame( $before_save_post_count, did_action( 'save_post' ) ); } + /** + * Test that the request is modified by od_decompress_rest_request_body(). + * + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request + * @covers ::od_decompress_rest_request_body + */ + public function test_od_decompress_rest_request_body_modifies_request(): void { + $params = $this->get_valid_params(); + $request = $this->create_request( $this->get_valid_params() ); + unset( $params['hmac'], $params['slug'], $params['current_etag'], $params['cache_purge_post_id'] ); + $json_data = wp_json_encode( $params ); + $result = od_decompress_rest_request_body( null, rest_get_server(), $request ); + + $this->assertNotWPError( $result ); + $this->assertEquals( $json_data, $request->get_body() ); + $this->assertEquals( 'application/json', $request->get_header( 'Content-Type' ) ); + } + + /** + * Test that the `od_maximum_url_metric_size` filter can be used to modify the maximum size of URL Metrics. + * + * @dataProvider data_provider_maximum_url_metrics_size_filter + * + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request + * @covers ::od_get_maximum_url_metric_size + * + * @param Closure $set_up Set up function. + * @param array $params Params. + * @param int $expected_status Expected status. + * @param string|null $expected_code Expected code. + * @param bool $expected_incorrect_usage Expected incorrect usage. + */ + public function test_maximum_url_metrics_size_filter( Closure $set_up, array $params, int $expected_status, ?string $expected_code, bool $expected_incorrect_usage ): void { + $set_up(); + if ( $expected_incorrect_usage ) { + $this->setExpectedIncorrectUsage( 'Filter: 'od_maximum_url_metric_size'' ); + } + $request = $this->create_request( $params ); + unset( $params['hmac'], $params['slug'], $params['current_etag'], $params['cache_purge_post_id'] ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $params ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( $expected_status, $response->get_status(), 'Response: ' . wp_json_encode( $response->get_data() ) ); + if ( null !== $expected_code ) { + $this->assertSame( $expected_code, $response->get_data()['code'] ); + } + } + + /** + * Data provider for test_maximum_url_metrics_size_filter. + * + * @return array Test data. + */ + public function data_provider_maximum_url_metrics_size_filter(): array { + $valid_params = $this->get_valid_params(); + $valid_element = $valid_params['elements'][0]; + + return array( + 'url_metrics_should_be_accepted_because_of_increased_maximum_url_metrics_size' => array( + 'set_up' => static function (): void { + add_filter( + 'od_maximum_url_metric_size', + static function (): int { + return MB_IN_BYTES * 2; + } + ); + }, + 'params' => array_merge( + $valid_params, + array( + // Fill the JSON with more than 1MB of data. + 'elements' => array( + array_merge( + $valid_element, + array( + 'xpath' => sprintf( '/HTML/BODY/DIV[@id=\'%s\']/*[1][self::DIV]', str_repeat( 'A', MB_IN_BYTES ) ), + ) + ), + ), + ) + ), + 'expected_status' => 200, + 'expected_code' => null, + 'expected_incorrect_usage' => false, + ), + 'url_metrics_should_be_rejected_because_of_decreased_maximum_url_metrics_size' => array( + 'set_up' => static function (): void { + add_filter( + 'od_maximum_url_metric_size', + static function (): int { + return MB_IN_BYTES / 2; + } + ); + }, + 'params' => array_merge( + $valid_params, + array( + // Fill the JSON with more than 1MB of data. + 'elements' => array( + array_merge( + $valid_element, + array( + 'xpath' => sprintf( '/HTML/BODY/DIV[@id=\'%s\']/*[1][self::DIV]', str_repeat( 'A', MB_IN_BYTES ) ), + ) + ), + ), + ) + ), + 'expected_status' => 413, + 'expected_code' => 'rest_content_too_large', + 'expected_incorrect_usage' => false, + ), + 'negative_maximum_url_metric_size_is_treated_as_1mb' => array( + 'set_up' => static function (): void { + add_filter( + 'od_maximum_url_metric_size', + static function (): int { + return -1; + } + ); + }, + 'params' => array_merge( + $valid_params, + array( + // Fill the JSON with more than 1MB of data. + 'elements' => array( + array_merge( + $valid_element, + array( + 'xpath' => sprintf( '/HTML/BODY/DIV[@id=\'%s\']/*[1][self::DIV]', str_repeat( 'A', MB_IN_BYTES / 2 ) ), + ) + ), + ), + ) + ), + 'expected_status' => 200, + 'expected_code' => null, + 'expected_incorrect_usage' => true, + ), + ); + } + /** * Populate URL Metrics. * @@ -937,11 +1117,11 @@ private function create_request( array $params ): WP_REST_Request { * @var WP_REST_Request> $request */ $request = new WP_REST_Request( 'POST', self::ROUTE ); - $request->set_header( 'Content-Type', 'application/json' ); + $request->set_header( 'Content-Type', 'application/gzip' ); $request->set_query_params( wp_array_slice_assoc( $params, array( 'hmac', 'current_etag', 'slug', 'cache_purge_post_id' ) ) ); $request->set_header( 'Origin', home_url() ); unset( $params['hmac'], $params['slug'], $params['current_etag'], $params['cache_purge_post_id'] ); - $request->set_body( wp_json_encode( $params ) ); + $request->set_body( gzencode( wp_json_encode( $params ) ) ); return $request; } } diff --git a/plugins/optimization-detective/tests/test-hooks.php b/plugins/optimization-detective/tests/test-hooks.php index 1a0ad677ed..822a0030f9 100644 --- a/plugins/optimization-detective/tests/test-hooks.php +++ b/plugins/optimization-detective/tests/test-hooks.php @@ -21,5 +21,6 @@ public function test_hooks_added(): void { $this->assertEquals( 10, has_filter( 'site_status_tests', 'od_add_rest_api_availability_test' ) ); $this->assertEquals( 10, has_action( 'admin_init', 'od_maybe_run_rest_api_health_check' ) ); $this->assertEquals( 30, has_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row' ) ); + $this->assertEquals( 10, has_filter( 'rest_pre_dispatch', 'od_decompress_rest_request_body' ) ); } }