PluginProbe
Optimization Detective / trunk
Optimization Detective vtrunk
1.0.0-beta7 trunk 0.1.0 0.1.1 0.2.0 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0 0.9.0 1.0.0-beta1 1.0.0-beta2 1.0.0-beta3 1.0.0-beta4 1.0.0-beta5 1.0.0-beta6
optimization-detective / detection.php

detection.php in Optimization Detective trunk, at detection.php

317 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Detection for Optimization Detective.
4 *
5 * @package optimization-detective
6 * @since 0.1.0
7 */
8
9 declare( strict_types = 1 );
10
11 // @codeCoverageIgnoreStart
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit; // Exit if accessed directly.
14 }
15 // @codeCoverageIgnoreEnd
16
17 /**
18 * Gets the ID for a post related to this response so that page caches can be told to invalidate their cache.
19 *
20 * 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
21 * post in The Loop.
22 *
23 * When the queried object is a post (e.g. is_singular, is_posts_page, is_front_page w/ show_on_front=page), then this
24 * is the perfect match. A page caching plugin will be able to most reliably invalidate the cache for a URL via
25 * this ID if the relevant actions are triggered for the post (e.g. clean_post_cache, save_post, transition_post_status).
26 *
27 * Otherwise, if the response is an archive page or the front page where show_on_front=posts (i.e. is_home), then
28 * there is no singular post object that represents the URL. In this case, we get the first post in the main
29 * loop. By triggering the relevant actions for this post ID, page caches will be more likely able to invalidate
30 * the related URLs. Page caching plugins which leverage surrogate keys will be the most reliable here. Otherwise,
31 * caching plugins may just resort to automatically purging the cache for the homepage whenever any post is edited,
32 * which is better than nothing.
33 *
34 * There should not be any situation by default in which a page optimized with Optimization Detective does not have such
35 * a post available for cache purging. As seen in {@see od_can_optimize_response()}, when such a post ID is not
36 * available for cache purging, then it returns false, as it also does in another case like if is_404().
37 *
38 * @since 0.8.0
39 * @access private
40 *
41 * @global WP_Query $wp_query WordPress Query object.
42 *
43 * @return positive-int|null Post ID or null if none found.
44 */
45 function od_get_cache_purge_post_id(): ?int {
46 $queried_object = get_queried_object();
47 if ( $queried_object instanceof WP_Post && $queried_object->ID > 0 ) {
48 return $queried_object->ID;
49 }
50
51 global $wp_query;
52 if (
53 $wp_query instanceof WP_Query
54 &&
55 $wp_query->post_count > 0
56 &&
57 isset( $wp_query->posts[0] )
58 &&
59 $wp_query->posts[0] instanceof WP_Post
60 &&
61 $wp_query->posts[0]->ID > 0
62 ) {
63 return $wp_query->posts[0]->ID;
64 }
65
66 return null;
67 }
68
69 /**
70 * Prints the scripts for the detect loader.
71 *
72 * @since 0.1.0
73 * @since 1.0.0 Renamed from od_get_detection_script().
74 * @access private
75 *
76 * @param non-empty-string $slug URL Metrics slug.
77 * @param OD_URL_Metric_Group_Collection $group_collection URL Metric group collection.
78 */
79 function od_get_detection_scripts( string $slug, OD_URL_Metric_Group_Collection $group_collection ): string {
80
81 /**
82 * Filters whether to use the web-vitals.js build with attribution.
83 *
84 * @since 1.0.0
85 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_use_web_vitals_attribution_build
86 *
87 * @param bool $use_attribution_build Whether to use the attribution build.
88 */
89 $use_attribution_build = (bool) apply_filters( 'od_use_web_vitals_attribution_build', false );
90
91 /**
92 * Lib data for web-vitals.
93 *
94 * @var array{ version: non-empty-string, dependencies: list<non-empty-string> } $web_vitals_lib_data
95 */
96 $web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php';
97 $web_vitals_lib_src = $use_attribution_build ?
98 plugins_url( 'build/web-vitals-attribution.js', __FILE__ ) :
99 plugins_url( 'build/web-vitals.js', __FILE__ );
100 $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], $web_vitals_lib_src );
101
102 /**
103 * Filters the list of extension script module URLs to import when performing detection.
104 *
105 * @since 0.7.0
106 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_extension_module_urls
107 *
108 * @param string[] $extension_module_urls Extension module URLs.
109 */
110 $extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() );
111
112 $cache_purge_post_id = od_get_cache_purge_post_id();
113 $current_url = od_get_current_url();
114 $current_etag = $group_collection->get_current_etag();
115
116 /**
117 * Filters whether URL Metric JSON data should be compressed with gzip when being submitted to the `/url-metrics:store` REST API endpoint.
118 *
119 * @since 1.0.0
120 * @link https://github.com/WordPress/performance/blob/trunk/plugins/optimization-detective/docs/hooks.md#:~:text=Filter%3A%20od_gzip_url_metric_store_request_payloads
121 *
122 * @param bool $gzip_url_metric_store_request_payloads Whether to use gzip to compress URL Metric JSON.
123 */
124 $gzdecode_available = function_exists( 'gzdecode' ) && apply_filters( 'od_gzip_url_metric_store_request_payloads', true );
125
126 $detect_src = add_query_arg(
127 array( 'ver' => OPTIMIZATION_DETECTIVE_VERSION ),
128 plugins_url( od_get_asset_path( 'detect.js' ), __FILE__ )
129 );
130
131 $detect_args = array(
132 'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(),
133 'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(),
134 'isDebug' => WP_DEBUG,
135 'extensionModuleUrls' => $extension_module_urls,
136 'restApiEndpoint' => rest_url( OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE ),
137 'currentETag' => $current_etag,
138 'currentUrl' => $current_url,
139 'urlMetricSlug' => $slug,
140 'cachePurgePostId' => od_get_cache_purge_post_id(),
141 'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_etag, $current_url, $cache_purge_post_id ),
142 'urlMetricGroupStatuses' => array_map(
143 static fn ( OD_URL_Metric_Group $group ) => array(
144 'minimumViewportWidth' => $group->get_minimum_viewport_width(), // Exclusive.
145 'maximumViewportWidth' => $group->get_maximum_viewport_width(), // Inclusive.
146 'complete' => $group->is_complete(),
147 ),
148 iterator_to_array( $group_collection )
149 ),
150 'storageLockTTL' => OD_Storage_Lock::get_ttl(),
151 'freshnessTTL' => od_get_url_metric_freshness_ttl(),
152 'webVitalsLibrarySrc' => $web_vitals_lib_src,
153 'gzdecodeAvailable' => $gzdecode_available,
154 'maxUrlMetricSize' => od_get_maximum_url_metric_size(),
155 );
156 if ( is_user_logged_in() ) {
157 $detect_args['restApiNonce'] = wp_create_nonce( 'wp_rest' );
158 }
159 if ( WP_DEBUG ) {
160 $detect_args['urlMetricGroupCollection'] = $group_collection;
161 }
162
163 $json_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
164 if ( SCRIPT_DEBUG ) {
165 $json_flags |= JSON_PRETTY_PRINT;
166 }
167 $json_script = wp_get_inline_script_tag(
168 (string) wp_json_encode(
169 array( $detect_src, $detect_args ),
170 $json_flags
171 ),
172 array(
173 'type' => 'application/json',
174 'id' => 'optimization-detective-detect-args',
175 )
176 );
177
178 $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.
179 $module_js .= sprintf(
180 "\n//# sourceURL=%s",
181 add_query_arg(
182 array( 'ver' => OPTIMIZATION_DETECTIVE_VERSION ),
183 plugins_url( od_get_asset_path( 'detect-loader.js' ), __FILE__ )
184 )
185 );
186
187 $module_script = wp_get_inline_script_tag(
188 $module_js,
189 array( 'type' => 'module' )
190 );
191
192 return $json_script . $module_script;
193 }
194
195 /**
196 * Registers the REST API endpoint for storing URL Metrics.
197 *
198 * @since 1.0.0
199 * @access private
200 */
201 function od_register_rest_url_metric_store_endpoint(): void {
202 $endpoint_controller = new OD_REST_URL_Metrics_Store_Endpoint();
203
204 register_rest_route(
205 $endpoint_controller::ROUTE_NAMESPACE,
206 $endpoint_controller::ROUTE_BASE,
207 $endpoint_controller->get_registration_args()
208 );
209 }
210
211 /**
212 * Decompresses the REST API request body for the URL Metrics endpoint.
213 *
214 * @since 1.0.0
215 * @access private
216 *
217 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
218 *
219 * @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.
220 * @param WP_REST_Server $server Server instance.
221 * @param WP_REST_Request $request Request used to generate the response.
222 * @return mixed Passed through $result if successful, or otherwise a WP_Error.
223 */
224 function od_decompress_rest_request_body( $result, WP_REST_Server $server, WP_REST_Request $request ) {
225 unset( $server ); // Unused.
226
227 if (
228 function_exists( 'gzdecode' ) &&
229 $request->get_route() === '/' . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_NAMESPACE . OD_REST_URL_Metrics_Store_Endpoint::ROUTE_BASE &&
230 'gzip' === $request->get_header( 'Content-Encoding' )
231 ) {
232 $compressed_body = $request->get_body();
233
234 /*
235 * The limit for data sent via navigator.sendBeacon() is 64 KiB. This limit is checked in detect.js so that the
236 * request will not even be attempted if the payload is too large. This server-side restriction is added as a
237 * safeguard against clients sending possibly malicious payloads much larger than 64 KiB which should never be
238 * getting sent.
239 */
240 $max_size = 64 * 1024; // 64 KB
241 $content_length = strlen( $compressed_body );
242 if ( $content_length > $max_size ) {
243 return new WP_Error(
244 'rest_content_too_large',
245 sprintf(
246 /* translators: 1: the size of the payload, 2: the maximum allowed payload size */
247 __( 'Compressed JSON payload size is %1$s bytes which is larger than the maximum allowed size of %2$s bytes.', 'optimization-detective' ),
248 number_format_i18n( $content_length ),
249 number_format_i18n( $max_size )
250 ),
251 array( 'status' => 413 )
252 );
253 }
254
255 $decompressed_body = @gzdecode( $compressed_body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We need to suppress errors here.
256
257 if ( false === $decompressed_body ) {
258 return new WP_Error(
259 'rest_invalid_payload',
260 __( 'Unable to decompress the gzip payload.', 'optimization-detective' ),
261 array( 'status' => 400 )
262 );
263 }
264
265 // Update the request so later handlers see the decompressed JSON.
266 $request->set_body( $decompressed_body );
267 $request->remove_header( 'Content-Encoding' );
268 }
269 return $result;
270 }
271
272 /**
273 * Triggers post update actions for page caches to invalidate their caches related to the supplied cache purge post ID.
274 *
275 * This is intended to flush any page cache for the URL after the new URL Metric was submitted so that the optimizations
276 * which depend on that URL Metric can start to take effect.
277 *
278 * @since 1.0.0
279 *
280 * @param positive-int $cache_purge_post_id Cache purge post ID.
281 */
282 function od_trigger_post_update_actions( int $cache_purge_post_id ): void {
283
284 $post = get_post( $cache_purge_post_id );
285 if ( ! ( $post instanceof WP_Post ) ) {
286 return;
287 }
288
289 // Fire actions that page caching plugins listen to flush caches.
290
291 /*
292 * The clean_post_cache action is used to flush page caches by:
293 * - Pantheon Advanced Cache <https://github.com/pantheon-systems/pantheon-advanced-page-cache/blob/e3b5552b0cb9268d9b696cb200af56cc044920d9/pantheon-advanced-page-cache.php#L185>
294 * - WP Super Cache <https://github.com/Automattic/wp-super-cache/blob/73b428d2fce397fd874b3056ad3120c343bc1a0c/wp-cache-phase2.php#L1615>
295 * - Batcache <https://github.com/Automattic/batcache/blob/ed0e6b2d9bcbab3924c49a6c3247646fb87a0957/batcache.php#L18>
296 */
297 /** This action is documented in wp-includes/post.php. */
298 do_action( 'clean_post_cache', $post->ID, $post );
299
300 /*
301 * The transition_post_status action is used to flush page caches by:
302 * - Jetpack Boost <https://github.com/Automattic/jetpack-boost-production/blob/4090a3f9414c2171cd52d8a397f00b0d1151475f/app/modules/optimizations/page-cache/pre-wordpress/Boost_Cache.php#L76>
303 * - WP Super Cache <https://github.com/Automattic/wp-super-cache/blob/73b428d2fce397fd874b3056ad3120c343bc1a0c/wp-cache-phase2.php#L1616>
304 * - LightSpeed Cache <https://github.com/litespeedtech/lscache_wp/blob/7c707469b3c88b4f45d9955593b92f9aeaed54c3/src/purge.cls.php#L68>
305 */
306 /** This action is documented in wp-includes/post.php. */
307 do_action( 'transition_post_status', $post->post_status, $post->post_status, $post );
308
309 /*
310 * The clean_post_cache action is used to flush page caches by:
311 * - W3 Total Cache <https://github.com/BoldGrid/w3-total-cache/blob/ab08f104294c6a8dcb00f1c66aaacd0615c42850/Util_AttachToActions.php#L32>
312 * - WP Rocket <https://github.com/wp-media/wp-rocket/blob/e5bca6673a3669827f3998edebc0c785210fe561/inc/common/purge.php#L283>
313 */
314 /** This action is documented in wp-includes/post.php. */
315 do_action( 'save_post', $post->ID, $post, /* $update */ true );
316 }
317