HEX
Server: Apache/2.4.65 (Debian)
System: Linux 88f31f35b0b8 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
User: www-data (33)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/optimization-detective/detection.php
<?php
/**
 * Detection for Optimization Detective.
 *
 * @package optimization-detective
 * @since 0.1.0
 */

// @codeCoverageIgnoreStart
if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}
// @codeCoverageIgnoreEnd

/**
 * Gets the ID for a post related to this response so that page caches can be told to invalidate their cache.
 *
 * If the queried object for the response is a post, then that post's ID is used. Otherwise, it uses the ID of the first
 * post in The Loop.
 *
 * When the queried object is a post (e.g. is_singular, is_posts_page, is_front_page w/ show_on_front=page), then this
 * is the perfect match. A page caching plugin will be able to most reliably invalidate the cache for a URL via
 * this ID if the relevant actions are triggered for the post (e.g. clean_post_cache, save_post, transition_post_status).
 *
 * Otherwise, if the response is an archive page or the front page where show_on_front=posts (i.e. is_home), then
 * there is no singular post object that represents the URL. In this case, we get the first post in the main
 * loop. By triggering the relevant actions for this post ID, page caches will be more likely able to invalidate
 * the related URLs. Page caching plugins which leverage surrogate keys will be the most reliable here. Otherwise,
 * caching plugins may just resort to automatically purging the cache for the homepage whenever any post is edited,
 * which is better than nothing.
 *
 * There should not be any situation by default in which a page optimized with Optimization Detective does not have such
 * a post available for cache purging. As seen in {@see od_can_optimize_response()}, when such a post ID is not
 * available for cache purging, then it returns false, as it also does in another case like if is_404().
 *
 * @since 0.8.0
 * @access private
 *
 * @global WP_Query $wp_query WordPress Query object.
 *
 * @return positive-int|null Post ID or null if none found.
 */
function od_get_cache_purge_post_id(): ?int {
	$queried_object = get_queried_object();
	if ( $queried_object instanceof WP_Post && $queried_object->ID > 0 ) {
		return $queried_object->ID;
	}

	global $wp_query;
	if (
		$wp_query instanceof WP_Query
		&&
		$wp_query->post_count > 0
		&&
		isset( $wp_query->posts[0] )
		&&
		$wp_query->posts[0] instanceof WP_Post
		&&
		$wp_query->posts[0]->ID > 0
	) {
		return $wp_query->posts[0]->ID;
	}

	return null;
}

/**
 * Prints the scripts for the detect loader.
 *
 * @since 0.1.0
 * @since 1.0.0 Renamed from od_get_detection_script().
 * @access private
 *
 * @param non-empty-string               $slug             URL Metrics slug.
 * @param OD_URL_Metric_Group_Collection $group_collection URL Metric group collection.
 */
function od_get_detection_scripts( string $slug, OD_URL_Metric_Group_Collection $group_collection ): string {

	/**
	 * Filters whether to use the web-vitals.js build with attribution.
	 *
	 * @since 1.0.0
	 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_use_web_vitals_attribution_build
	 *
	 * @param bool $use_attribution_build Whether to use the attribution build.
	 */
	$use_attribution_build = (bool) apply_filters( 'od_use_web_vitals_attribution_build', false );

	$web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php';
	$web_vitals_lib_src  = $use_attribution_build ?
		plugins_url( 'build/web-vitals-attribution.js', __FILE__ ) :
		plugins_url( 'build/web-vitals.js', __FILE__ );
	$web_vitals_lib_src  = add_query_arg( 'ver', $web_vitals_lib_data['version'], $web_vitals_lib_src );

	/**
	 * Filters the list of extension script module URLs to import when performing detection.
	 *
	 * @since 0.7.0
	 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_extension_module_urls
	 *
	 * @param string[] $extension_module_urls Extension module URLs.
	 */
	$extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() );

	$cache_purge_post_id = od_get_cache_purge_post_id();
	$current_url         = od_get_current_url();
	$current_etag        = $group_collection->get_current_etag();

	/**
	 * Filters whether URL Metric JSON data should be compressed with gzip when being submitted to the `/url-metrics:store` REST API endpoint.
	 *
	 * @since 1.0.0
	 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_gzip_url_metric_store_request_payloads
	 *
	 * @param bool $gzip_url_metric_store_request_payloads Whether to use gzip to compress URL Metric JSON.
	 */
	$gzdecode_available = function_exists( 'gzdecode' ) && apply_filters( 'od_gzip_url_metric_store_request_payloads', true );

	$detect_src = add_query_arg(
		array( 'ver' => OPTIMIZATION_DETECTIVE_VERSION ),
		plugins_url( od_get_asset_path( 'detect.js' ), __FILE__ )
	);

	$detect_args = array(
		'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(),
		'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(),
		'isDebug'                => WP_DEBUG,
		'extensionModuleUrls'    => $extension_module_urls,
		'restApiEndpoint'        => rest_url( OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE ),
		'currentETag'            => $current_etag,
		'currentUrl'             => $current_url,
		'urlMetricSlug'          => $slug,
		'cachePurgePostId'       => od_get_cache_purge_post_id(),
		'urlMetricHMAC'          => od_get_url_metrics_storage_hmac( $slug, $current_etag, $current_url, $cache_purge_post_id ),
		'urlMetricGroupStatuses' => array_map(
			static function ( OD_URL_Metric_Group $group ): array {
				return array(
					'minimumViewportWidth' => $group->get_minimum_viewport_width(), // Exclusive.
					'maximumViewportWidth' => $group->get_maximum_viewport_width(), // Inclusive.
					'complete'             => $group->is_complete(),
				);
			},
			iterator_to_array( $group_collection )
		),
		'storageLockTTL'         => OD_Storage_Lock::get_ttl(),
		'freshnessTTL'           => od_get_url_metric_freshness_ttl(),
		'webVitalsLibrarySrc'    => $web_vitals_lib_src,
		'gzdecodeAvailable'      => $gzdecode_available,
		'maxUrlMetricSize'       => od_get_maximum_url_metric_size(),
	);
	if ( is_user_logged_in() ) {
		$detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' );
	}
	if ( WP_DEBUG ) {
		$detect_args['urlMetricGroupCollection'] = $group_collection;
	}

	$json_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
	if ( SCRIPT_DEBUG ) {
		$json_flags |= JSON_PRETTY_PRINT;
	}
	$json_script = wp_get_inline_script_tag(
		(string) wp_json_encode(
			array( $detect_src, $detect_args ),
			$json_flags
		),
		array(
			'type' => 'application/json',
			'id'   => 'optimization-detective-detect-args',
		)
	);

	$module_js  = file_get_contents( __DIR__ . '/' . od_get_asset_path( 'detect-loader.js' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request.
	$module_js .= sprintf(
		"\n//# sourceURL=%s",
		add_query_arg(
			array( 'ver' => OPTIMIZATION_DETECTIVE_VERSION ),
			plugins_url( od_get_asset_path( 'detect-loader.js' ), __FILE__ )
		)
	);

	$module_script = wp_get_inline_script_tag(
		$module_js,
		array( 'type' => 'module' )
	);

	return $json_script . $module_script;
}

/**
 * Registers the REST API endpoint for storing URL Metrics.
 *
 * @since 1.0.0
 * @access private
 */
function od_register_rest_url_metric_store_endpoint(): void {
	$endpoint_controller = new OD_REST_URL_Metrics_Store_Endpoint();

	register_rest_route(
		$endpoint_controller::ROUTE_NAMESPACE,
		$endpoint_controller::ROUTE_BASE,
		$endpoint_controller->get_registration_args()
	);
}

/**
 * Decompresses the REST API request body for the URL Metrics endpoint.
 *
 * @since 1.0.0
 * @access private
 *
 * @phpstan-param WP_REST_Request<array<string, mixed>> $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 ) {
	unset( $server ); // Unused.

	if (
		function_exists( 'gzdecode' ) &&
		$request->get_route() === '/' . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE &&
		'gzip' === $request->get_header( 'Content-Encoding' )
	) {
		$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->remove_header( 'Content-Encoding' );
	}
	return $result;
}

/**
 * Triggers post update actions for page caches to invalidate their caches related to the supplied cache purge post ID.
 *
 * This is intended to flush any page cache for the URL after the new URL Metric was submitted so that the optimizations
 * which depend on that URL Metric can start to take effect.
 *
 * @since 1.0.0
 *
 * @param positive-int $cache_purge_post_id Cache purge post ID.
 */
function od_trigger_post_update_actions( int $cache_purge_post_id ): void {

	$post = get_post( $cache_purge_post_id );
	if ( ! ( $post instanceof WP_Post ) ) {
		return;
	}

	// Fire actions that page caching plugins listen to flush caches.

	/*
	* The clean_post_cache action is used to flush page caches by:
	* - Pantheon Advanced Cache <https://github.com/pantheon-systems/pantheon-advanced-page-cache/blob/e3b5552b0cb9268d9b696cb200af56cc044920d9/pantheon-advanced-page-cache.php#L185>
	* - WP Super Cache <https://github.com/Automattic/wp-super-cache/blob/73b428d2fce397fd874b3056ad3120c343bc1a0c/wp-cache-phase2.php#L1615>
	* - Batcache <https://github.com/Automattic/batcache/blob/ed0e6b2d9bcbab3924c49a6c3247646fb87a0957/batcache.php#L18>
	*/
	/** This action is documented in wp-includes/post.php. */
	do_action( 'clean_post_cache', $post->ID, $post );

	/*
	* The transition_post_status action is used to flush page caches by:
	* - Jetpack Boost <https://github.com/Automattic/jetpack-boost-production/blob/4090a3f9414c2171cd52d8a397f00b0d1151475f/app/modules/optimizations/page-cache/pre-wordpress/Boost_Cache.php#L76>
	* - WP Super Cache <https://github.com/Automattic/wp-super-cache/blob/73b428d2fce397fd874b3056ad3120c343bc1a0c/wp-cache-phase2.php#L1616>
	* - LightSpeed Cache <https://github.com/litespeedtech/lscache_wp/blob/7c707469b3c88b4f45d9955593b92f9aeaed54c3/src/purge.cls.php#L68>
	*/
	/** This action is documented in wp-includes/post.php. */
	do_action( 'transition_post_status', $post->post_status, $post->post_status, $post );

	/*
	* The clean_post_cache action is used to flush page caches by:
	* - W3 Total Cache <https://github.com/BoldGrid/w3-total-cache/blob/ab08f104294c6a8dcb00f1c66aaacd0615c42850/Util_AttachToActions.php#L32>
	* - WP Rocket <https://github.com/wp-media/wp-rocket/blob/e5bca6673a3669827f3998edebc0c785210fe561/inc/common/purge.php#L283>
	*/
	/** This action is documented in wp-includes/post.php. */
	do_action( 'save_post', $post->ID, $post, /* $update */ true );
}