diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index bf2645392c..f909a3473e 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -96,33 +96,65 @@ function error( ...message ) { } /** - * Checks whether the URL Metric(s) for the provided viewport width is needed. + * Gets the status for the URL Metric group for the provided viewport width. * * The comparison logic here corresponds with the PHP logic in `OD_URL_Metric_Group::is_viewport_width_in_range()`. + * This function is also similar to the PHP logic in `\OD_URL_Metric_Group_Collection::get_group_for_viewport_width()`. * * @param {number} viewportWidth - Current viewport width. * @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses. - * @return {boolean} Whether URL Metrics are needed. + * @return {URLMetricGroupStatus} The URL metric group for the viewport width. */ -function isViewportNeeded( viewportWidth, urlMetricGroupStatuses ) { - if ( viewportWidth === 0 ) { - return false; - } - - for ( const { - minimumViewportWidth, - maximumViewportWidth, - complete, - } of urlMetricGroupStatuses ) { +function getGroupForViewportWidth( viewportWidth, urlMetricGroupStatuses ) { + for ( const urlMetricGroupStatus of urlMetricGroupStatuses ) { if ( - viewportWidth > minimumViewportWidth && - ( null === maximumViewportWidth || - viewportWidth <= maximumViewportWidth ) + viewportWidth > urlMetricGroupStatus.minimumViewportWidth && + ( null === urlMetricGroupStatus.maximumViewportWidth || + viewportWidth <= urlMetricGroupStatus.maximumViewportWidth ) ) { - return ! complete; + return urlMetricGroupStatus; } } - return false; + throw new Error( + `${ consoleLogPrefix } Unexpectedly unable to locate group for the current viewport width.` + ); +} + +/** + * Gets the sessionStorage key for keeping track of whether the current client session already submitted a URL Metric. + * + * @param {string} currentETag - Current ETag. + * @param {string} currentUrl - Current URL. + * @param {URLMetricGroupStatus} urlMetricGroupStatus - URL Metric group status. + * @return {Promise} Session storage key. + */ +async function getAlreadySubmittedSessionStorageKey( + currentETag, + currentUrl, + urlMetricGroupStatus +) { + const message = [ + currentETag, + currentUrl, + urlMetricGroupStatus.minimumViewportWidth, + urlMetricGroupStatus.maximumViewportWidth || '', + ].join( '-' ); + + /* + * Note that the components are hashed for a couple of reasons: + * + * 1. It results in a consistent length string devoid of any special characters that could cause problems. + * 2. Since the key includes the URL, hashing it avoids potential privacy concerns where the sessionStorage is + * examined to see which URLs the client went to. + * + * The SHA-1 algorithm is chosen since it is the fastest and there is no need for cryptographic security. + */ + const msgBuffer = new TextEncoder().encode( message ); + const hashBuffer = await crypto.subtle.digest( 'SHA-1', msgBuffer ); + const hashHex = Array.from( new Uint8Array( hashBuffer ) ) + .map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) ) + .join( '' ); + return `odSubmitted-${ hashHex }`; } /** @@ -264,6 +296,7 @@ function extendElementData( xpath, properties ) { * @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. @@ -271,6 +304,7 @@ function extendElementData( xpath, properties ) { * @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. */ @@ -280,6 +314,7 @@ export default async function detect( { isDebug, extensionModuleUrls, restApiEndpoint, + restApiNonce, currentETag, currentUrl, urlMetricSlug, @@ -287,6 +322,7 @@ export default async function detect( { urlMetricHMAC, urlMetricGroupStatuses, storageLockTTL, + freshnessTTL, webVitalsLibrarySrc, urlMetricGroupCollection, } ) { @@ -308,14 +344,52 @@ export default async function detect( { ); } + if ( win.innerWidth === 0 || win.innerHeight === 0 ) { + if ( isDebug ) { + log( + 'Window must have non-zero dimensions for URL Metric collection.' + ); + } + return; + } + // Abort if the current viewport is not among those which need URL Metrics. - if ( ! isViewportNeeded( win.innerWidth, urlMetricGroupStatuses ) ) { + const urlMetricGroupStatus = getGroupForViewportWidth( + win.innerWidth, + urlMetricGroupStatuses + ); + if ( urlMetricGroupStatus.complete ) { if ( isDebug ) { log( 'No need for URL Metrics from the current viewport.' ); } return; } + // Abort if the client already submitted a URL Metric for this URL and viewport group. + const alreadySubmittedSessionStorageKey = + await getAlreadySubmittedSessionStorageKey( + currentETag, + currentUrl, + urlMetricGroupStatus + ); + if ( alreadySubmittedSessionStorageKey in sessionStorage ) { + const previousVisitTime = parseInt( + sessionStorage.getItem( alreadySubmittedSessionStorageKey ), + 10 + ); + if ( + ! isNaN( previousVisitTime ) && + ( getCurrentTime() - previousVisitTime ) / 1000 < freshnessTTL + ) { + if ( isDebug ) { + log( + 'The current client session already submitted a fresh URL Metric for this URL so a new one will not be collected now.' + ); + return; + } + } + } + // Abort if the viewport aspect ratio is not in a common range. const aspectRatio = win.innerWidth / win.innerHeight; if ( @@ -670,11 +744,20 @@ export default async function detect( { // because we can't look at the response when sending a beacon. setStorageLock( getCurrentTime() ); + // Remember that the URL Metric was submitted for this URL to avoid having multiple entries submitted by the same client. + sessionStorage.setItem( + alreadySubmittedSessionStorageKey, + String( getCurrentTime() ) + ); + if ( isDebug ) { log( 'Sending URL Metric:', urlMetric ); } const url = new URL( restApiEndpoint ); + if ( typeof restApiNonce === 'string' ) { + url.searchParams.set( '_wpnonce', restApiNonce ); + } url.searchParams.set( 'slug', urlMetricSlug ); url.searchParams.set( 'current_etag', currentETag ); if ( typeof cachePurgePostId === 'number' ) { diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index b9b9a8d1d6..9ab3f6d0ae 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -138,8 +138,12 @@ static function ( OD_URL_Metric_Group $group ): array { iterator_to_array( $group_collection ) ), 'storageLockTTL' => OD_Storage_Lock::get_ttl(), + 'freshnessTTL' => od_get_url_metric_freshness_ttl(), 'webVitalsLibrarySrc' => $web_vitals_lib_src, ); + if ( is_user_logged_in() ) { + $detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' ); + } if ( WP_DEBUG ) { $detect_args['urlMetricGroupCollection'] = $group_collection; } diff --git a/plugins/optimization-detective/docs/hooks.md b/plugins/optimization-detective/docs/hooks.md index 09e20bad44..29edbb9347 100644 --- a/plugins/optimization-detective/docs/hooks.md +++ b/plugins/optimization-detective/docs/hooks.md @@ -102,9 +102,11 @@ add_filter( 'od_url_metrics_breakpoint_sample_size', function (): int { } ); ``` -### Filter: `od_url_metric_storage_lock_ttl` (default: 1 minute in seconds) +### Filter: `od_url_metric_storage_lock_ttl` (default: 60 seconds, except 0 for authorized logged-in users) -Filters how long a given IP is locked from submitting another metric-storage REST API request. Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable locking when a user is logged-in with code like the following: +Filters how long the current IP is locked from submitting another URL metric storage REST API request. + +Filtering the TTL to zero will disable any URL Metric storage locking. This is useful, for example, to disable locking when a user is logged-in with code like the following: ```php add_filter( 'od_metrics_storage_lock_ttl', function ( int $ttl ): int { @@ -112,6 +114,8 @@ add_filter( 'od_metrics_storage_lock_ttl', function ( int $ttl ): int { } ); ``` +By default, the TTL is zero (0) for authorized users and sixty (60) for everyone else. Whether the current user is authorized is determined by whether the user has the `od_store_url_metric_now` capability. This custom capability by default maps to the `manage_options` primitive capability via the `user_has_cap` filter. + During development this is useful to set to zero so you can quickly collect new URL Metrics by reloading the page without having to wait for the storage lock to release: ```php diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index ff2908390f..2e9c9176b0 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -18,6 +18,7 @@ add_action( 'init', 'od_initialize_extensions', PHP_INT_MAX ); add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX ); OD_URL_Metrics_Post_Type::add_hooks(); +OD_Storage_Lock::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); add_action( 'wp_head', 'od_render_generator_meta_tag' ); add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' ); diff --git a/plugins/optimization-detective/storage/class-od-storage-lock.php b/plugins/optimization-detective/storage/class-od-storage-lock.php index 3f02b224dd..1ceb705e4e 100644 --- a/plugins/optimization-detective/storage/class-od-storage-lock.php +++ b/plugins/optimization-detective/storage/class-od-storage-lock.php @@ -20,6 +20,44 @@ */ final class OD_Storage_Lock { + /** + * Capability for being able to store a URL Metric now. + * + * @since n.e.x.t + * @access private + * @var string + */ + const STORE_URL_METRIC_NOW_CAPABILITY = 'od_store_url_metric_now'; + + /** + * Adds hooks. + * + * @since n.e.x.t + * @access private + */ + public static function add_hooks(): void { + add_filter( 'user_has_cap', array( __CLASS__, 'filter_user_has_cap' ) ); + } + + /** + * Filters `user_has_cap` to grant the `od_store_url_metric_now` capability to users who can `manage_options` by default. + * + * @since n.e.x.t + * @access private + * + * @param array|mixed $allcaps Capability names mapped to boolean values for whether the user has that capability. + * @return array Capability names mapped to boolean values for whether the user has that capability. + */ + public static function filter_user_has_cap( $allcaps ): array { + if ( ! is_array( $allcaps ) ) { + $allcaps = array(); + } + if ( isset( $allcaps['manage_options'] ) ) { + $allcaps['od_store_url_metric_now'] = $allcaps['manage_options']; + } + return $allcaps; + } + /** * Gets the TTL (in seconds) for the URL Metric storage lock. * @@ -29,22 +67,28 @@ final class OD_Storage_Lock { * @return int<0, max> TTL in seconds, greater than or equal to zero. A value of zero means that the storage lock should be disabled and thus that transients must not be used. */ public static function get_ttl(): int { + $ttl = current_user_can( self::STORE_URL_METRIC_NOW_CAPABILITY ) ? 0 : MINUTE_IN_SECONDS; /** - * Filters how long a given IP is locked from submitting another metric-storage REST API request. + * Filters how long the current IP is locked from submitting another URL metric storage REST API request. * - * Filtering the TTL to zero will disable any metric storage locking. This is useful, for example, to disable + * Filtering the TTL to zero will disable any URL Metric storage locking. This is useful, for example, to disable * locking when a user is logged-in with code like the following: * * add_filter( 'od_metrics_storage_lock_ttl', static function ( int $ttl ): int { * return is_user_logged_in() ? 0 : $ttl; * } ); * + * By default, the TTL is zero (0) for authorized users and sixty (60) for everyone else. Whether the current + * user is authorized is determined by whether the user has the `od_store_url_metric_now` capability. This + * custom capability by default maps to the `manage_options` primitive capability via the `user_has_cap` filter. + * * @since 0.1.0 + * @since 1.0.0 This now defaults to zero (0) for authorized users. * - * @param int $ttl TTL. + * @param int $ttl TTL. Defaults to 60, except zero (0) for authorized users. */ - $ttl = (int) apply_filters( 'od_url_metric_storage_lock_ttl', MINUTE_IN_SECONDS ); + $ttl = (int) apply_filters( 'od_url_metric_storage_lock_ttl', $ttl ); return max( 0, $ttl ); } diff --git a/plugins/optimization-detective/tests/storage/test-class-od-storage-lock.php b/plugins/optimization-detective/tests/storage/test-class-od-storage-lock.php index 4c115f162f..23db2ef483 100644 --- a/plugins/optimization-detective/tests/storage/test-class-od-storage-lock.php +++ b/plugins/optimization-detective/tests/storage/test-class-od-storage-lock.php @@ -17,6 +17,76 @@ public function tear_down(): void { parent::tear_down(); } + /** + * Test add_hooks(). + * + * @covers ::add_hooks + */ + public function test_add_hooks(): void { + remove_all_filters( 'user_has_cap' ); + + OD_Storage_Lock::add_hooks(); + + $this->assertSame( + 10, + has_filter( 'user_has_cap', array( OD_Storage_Lock::class, 'filter_user_has_cap' ) ) + ); + } + + + /** + * Data provider. + * + * @return array + */ + public function data_filter_user_has_cap(): array { + return array( + 'caps_null_irrelevant' => array( + 'allcaps' => null, + 'expected' => array(), + ), + 'caps_irrelevant' => array( + 'allcaps' => array( 'edit_posts' => true ), + 'expected' => array( 'edit_posts' => true ), + ), + 'caps_relevant_allowed' => array( + 'allcaps' => array( + 'edit_posts' => true, + 'manage_options' => true, + ), + 'expected' => array( + 'edit_posts' => true, + 'manage_options' => true, + OD_Storage_Lock::STORE_URL_METRIC_NOW_CAPABILITY => true, + ), + ), + 'caps_relevant_disallowed' => array( + 'allcaps' => array( + 'edit_posts' => true, + 'manage_options' => false, + ), + 'expected' => array( + 'edit_posts' => true, + 'manage_options' => false, + OD_Storage_Lock::STORE_URL_METRIC_NOW_CAPABILITY => false, + ), + ), + ); + } + + /** + * Test filter_user_has_cap(). + * + * @dataProvider data_filter_user_has_cap + * @covers ::filter_user_has_cap + * + * @param array|null $allcaps Existing capabilities. + * @param array $expected Expected capabilities. + */ + public function test_filter_user_has_cap( ?array $allcaps, array $expected ): void { + $this->assertSame( $expected, OD_Storage_Lock::filter_user_has_cap( $allcaps ) ); + } + /** * Data provider. * @@ -24,11 +94,17 @@ public function tear_down(): void { */ public function data_provider_get_ttl(): array { return array( - 'unfiltered' => array( + 'unfiltered' => array( 'set_up' => static function (): void {}, 'expected' => MINUTE_IN_SECONDS, ), - 'filtered_hour' => array( + 'unfiltered_admin' => array( + 'set_up' => static function (): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + }, + 'expected' => 0, + ), + 'filtered_hour' => array( 'set_up' => static function (): void { add_filter( 'od_url_metric_storage_lock_ttl', @@ -39,7 +115,7 @@ static function (): int { }, 'expected' => HOUR_IN_SECONDS, ), - 'filtered_negative' => array( + 'filtered_negative' => array( 'set_up' => static function (): void { add_filter( 'od_url_metric_storage_lock_ttl', @@ -50,6 +126,23 @@ static function (): int { }, 'expected' => 0, ), + 'granted_subscriber' => array( + 'set_up' => static function (): void { + add_filter( + 'map_meta_cap', + static function ( array $caps, string $cap, int $user_id ): array { + $primitive_cap = 'exist'; + if ( OD_Storage_Lock::STORE_URL_METRIC_NOW_CAPABILITY === $cap && user_can( $user_id, $primitive_cap ) ) { + $caps = array( $primitive_cap ); + } + return $caps; + }, + 10, + 3 + ); + }, + 'expected' => 0, + ), ); } @@ -57,6 +150,7 @@ static function (): int { * Test get_ttl(). * * @covers ::get_ttl + * @covers ::filter_user_has_cap * * @dataProvider data_provider_get_ttl * diff --git a/plugins/optimization-detective/tests/test-detection.php b/plugins/optimization-detective/tests/test-detection.php index abff7eff0f..31876d0fad 100644 --- a/plugins/optimization-detective/tests/test-detection.php +++ b/plugins/optimization-detective/tests/test-detection.php @@ -84,15 +84,28 @@ public function test_od_get_cache_purge_post_id( Closure $set_up, bool $expected */ public function data_provider_od_get_detection_script(): array { return array( - 'unfiltered' => array( + 'unfiltered' => array( 'set_up' => static function (): void {}, 'expected_exports' => array( 'storageLockTTL' => MINUTE_IN_SECONDS, 'extensionModuleUrls' => array(), + 'cachePurgePostId' => null, + 'freshnessTTL' => DAY_IN_SECONDS, ), 'expected_standard_build' => true, ), - 'filtered' => array( + 'unfiltered_admin' => array( + 'set_up' => static function (): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + }, + 'expected_exports' => array( + 'storageLockTTL' => 0, + 'extensionModuleUrls' => array(), + 'cachePurgePostId' => null, + ), + 'expected_standard_build' => true, + ), + 'filtered' => array( 'set_up' => static function (): void { add_filter( 'od_url_metric_storage_lock_ttl', @@ -107,11 +120,38 @@ static function ( array $urls ): array { return $urls; } ); + add_filter( + 'od_minimum_viewport_aspect_ratio', + static function () { + return 0; + } + ); + add_filter( + 'od_maximum_viewport_aspect_ratio', + static function () { + return 2; + } + ); add_filter( 'od_use_web_vitals_attribution_build', '__return_true' ); + add_filter( + 'od_url_metric_storage_lock_ttl', + static function () { + return DAY_IN_SECONDS; + } + ); + add_filter( + 'od_url_metric_freshness_ttl', + static function () { + return WEEK_IN_SECONDS; + } + ); }, 'expected_exports' => array( - 'storageLockTTL' => HOUR_IN_SECONDS, - 'extensionModuleUrls' => array( home_url( '/my-extension.js', 'https' ) ), + 'storageLockTTL' => DAY_IN_SECONDS, + 'freshnessTTL' => WEEK_IN_SECONDS, + 'extensionModuleUrls' => array( home_url( '/my-extension.js', 'https' ) ), + 'minViewportAspectRatio' => 0, + 'maxViewportAspectRatio' => 2, ), 'expected_standard_build' => false, ), @@ -123,6 +163,15 @@ static function ( array $urls ): array { * * @covers ::od_get_detection_script * @covers ::od_get_asset_path + * @covers OD_Storage_Lock::get_ttl + * @covers ::od_get_cache_purge_post_id + * @covers ::od_get_minimum_viewport_aspect_ratio + * @covers ::od_get_maximum_viewport_aspect_ratio + * @covers ::od_get_current_url + * @covers ::od_get_url_metrics_storage_hmac + * @covers OD_URL_Metric_Group::get_minimum_viewport_width + * @covers OD_URL_Metric_Group::is_complete + * @covers OD_URL_Metric_Group_Collection::get_current_etag * * @dataProvider data_provider_od_get_detection_script * @@ -132,9 +181,22 @@ static function ( array $urls ): array { */ public function test_od_get_detection_script_returns_script( Closure $set_up, array $expected_exports, bool $expected_standard_build ): void { $set_up(); - $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); + $slug = od_get_url_metrics_slug( array() ); $current_etag = md5( '' ); + $expected_exports = array_merge( + array( + 'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(), + 'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(), + 'isDebug' => WP_DEBUG, + 'currentUrl' => od_get_current_url(), + 'urlMetricSlug' => $slug, + 'cachePurgePostId' => od_get_cache_purge_post_id(), + 'freshnessTTL' => od_get_url_metric_freshness_ttl(), + ), + $expected_exports + ); + $breakpoints = array( 480, 600, 782 ); $group_collection = new OD_URL_Metric_Group_Collection( array(), $current_etag, $breakpoints, 3, HOUR_IN_SECONDS ); @@ -145,6 +207,7 @@ public function test_od_get_detection_script_returns_script( Closure $set_up, ar foreach ( $expected_exports as $key => $value ) { $this->assertStringContainsString( sprintf( '%s:%s', wp_json_encode( $key ), wp_json_encode( $value ) ), $script ); } + $this->assertStringContainsString( '"urlMetricHMAC":', $script ); $this->assertSame( 1, preg_match( '/"webVitalsLibrarySrc":("[^"]+?")/', $script, $matches ) ); $web_vitals_library_src = json_decode( $matches[1] ); $this->assertStringContainsString( @@ -156,5 +219,10 @@ public function test_od_get_detection_script_returns_script( Closure $set_up, ar $this->assertStringContainsString( '"minimumViewportWidth":600', $script ); $this->assertStringContainsString( '"minimumViewportWidth":782', $script ); $this->assertStringContainsString( '"complete":false', $script ); + if ( is_user_logged_in() ) { + $this->assertStringContainsString( '"restApiNonce":', $script ); + } else { + $this->assertStringNotContainsString( '"restApiNonce":', $script ); + } } }