Skip to content

Commit c270c43

Browse files
authored
Merge pull request #1762 from b1ink0/add/site-health-check-for-od-rest-api
Add site health check to detect blocked REST API and short-circuit optimization when Inaccessible
2 parents abbdd78 + 14e9471 commit c270c43

File tree

11 files changed

+847
-40
lines changed

11 files changed

+847
-40
lines changed

plugins/optimization-detective/helper.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* Initializes extensions for Optimization Detective.
1515
*
1616
* @since 0.7.0
17+
* @access private
1718
*/
1819
function od_initialize_extensions(): void {
1920
/**
@@ -29,6 +30,9 @@ function od_initialize_extensions(): void {
2930
/**
3031
* Generates a media query for the provided minimum and maximum viewport widths.
3132
*
33+
* This helper function is available for extensions to leverage when manually printing STYLE rules via
34+
* {@see OD_HTML_Tag_Processor::append_head_html()} or {@see OD_HTML_Tag_Processor::append_body_html()}
35+
*
3236
* @since 0.7.0
3337
*
3438
* @param int|null $minimum_viewport_width Minimum viewport width.
@@ -59,16 +63,25 @@ function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_vi
5963
* See {@see 'wp_head'}.
6064
*
6165
* @since 0.1.0
66+
* @access private
6267
*/
6368
function od_render_generator_meta_tag(): void {
6469
// Use the plugin slug as it is immutable.
65-
echo '<meta name="generator" content="optimization-detective ' . esc_attr( OPTIMIZATION_DETECTIVE_VERSION ) . '">' . "\n";
70+
$content = 'optimization-detective ' . OPTIMIZATION_DETECTIVE_VERSION;
71+
72+
// Indicate that the plugin will not be doing anything because the REST API is unavailable.
73+
if ( od_is_rest_api_unavailable() ) {
74+
$content .= '; rest_api_unavailable';
75+
}
76+
77+
echo '<meta name="generator" content="' . esc_attr( $content ) . '">' . "\n";
6678
}
6779

6880
/**
6981
* Gets the path to a script or stylesheet.
7082
*
7183
* @since 0.9.0
84+
* @access private
7285
*
7386
* @param string $src_path Source path, relative to plugin root.
7487
* @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path.

plugins/optimization-detective/hooks.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@
1515
OD_URL_Metrics_Post_Type::add_hooks();
1616
add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' );
1717
add_action( 'wp_head', 'od_render_generator_meta_tag' );
18+
add_filter( 'site_status_tests', 'od_add_rest_api_availability_test' );
19+
add_action( 'admin_init', 'od_maybe_run_rest_api_health_check' );
20+
add_action( 'after_plugin_row_meta', 'od_render_rest_api_health_check_admin_notice_in_plugin_row', 30 );

plugins/optimization-detective/load.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,8 @@ class_alias( OD_URL_Metric_Group_Collection::class, 'OD_URL_Metrics_Group_Collec
127127

128128
// Add hooks for the above requires.
129129
require_once __DIR__ . '/hooks.php';
130+
131+
// Load site health checks.
132+
require_once __DIR__ . '/site-health.php';
130133
}
131134
);

plugins/optimization-detective/optimization.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ static function ( string $output, ?int $phase ): string {
7878
* @access private
7979
*/
8080
function od_maybe_add_template_output_buffer_filter(): void {
81-
if ( ! od_can_optimize_response() || isset( $_GET['optimization_detective_disabled'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
81+
if (
82+
! od_can_optimize_response() ||
83+
od_is_rest_api_unavailable() ||
84+
isset( $_GET['optimization_detective_disabled'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
85+
86+
) {
8287
return;
8388
}
8489
$callback = 'od_optimize_template_output_buffer';
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
<?php
2+
/**
3+
* Site Health checks.
4+
*
5+
* @package optimization-detective
6+
* @since n.e.x.t
7+
*/
8+
9+
if ( ! defined( 'ABSPATH' ) ) {
10+
exit; // Exit if accessed directly.
11+
}
12+
13+
/**
14+
* Adds the Optimization Detective REST API check to site health tests.
15+
*
16+
* @since n.e.x.t
17+
* @access private
18+
*
19+
* @param array{direct: array<string, array{label: string, test: string}>}|mixed $tests Site Health Tests.
20+
* @return array{direct: array<string, array{label: string, test: string}>} Amended tests.
21+
*/
22+
function od_add_rest_api_availability_test( $tests ): array {
23+
if ( ! is_array( $tests ) ) {
24+
$tests = array();
25+
}
26+
$tests['direct']['optimization_detective_rest_api'] = array(
27+
'label' => __( 'Optimization Detective REST API Endpoint Availability', 'optimization-detective' ),
28+
'test' => static function () {
29+
// Note: A closure is used here to improve symbol discovery for the sake of potential refactoring.
30+
return od_test_rest_api_availability();
31+
},
32+
);
33+
34+
return $tests;
35+
}
36+
37+
/**
38+
* Tests availability of the Optimization Detective REST API endpoint.
39+
*
40+
* @since n.e.x.t
41+
* @access private
42+
*
43+
* @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result.
44+
*/
45+
function od_test_rest_api_availability(): array {
46+
$response = od_get_rest_api_health_check_response( false );
47+
$result = od_compose_site_health_result( $response );
48+
$is_unavailable = 'good' !== $result['status'];
49+
update_option(
50+
'od_rest_api_unavailable',
51+
$is_unavailable ? '1' : '0',
52+
true // Intentionally autoloaded since used on every frontend request.
53+
);
54+
return $result;
55+
}
56+
57+
/**
58+
* Checks whether the Optimization Detective REST API endpoint is unavailable.
59+
*
60+
* This merely checks the database option what was previously computed in the Site Health test as done in {@see od_test_rest_api_availability()}.
61+
* This is to avoid checking for REST API availability during a frontend request. Note that when the plugin is first
62+
* installed, the 'od_rest_api_unavailable' option will not be in the database, as the check has not been performed
63+
* yet. Once Site Health's weekly check happens or when a user accesses the admin so that the admin_init action fires,
64+
* then at this point the check will be performed at {@see od_maybe_run_rest_api_health_check()}. In practice, this will
65+
* happen immediately after the user activates a plugin since the user is redirected back to the plugin list table in
66+
* the admin. The reason for storing the negative unavailable state as opposed to the positive available state is that
67+
* when an option does not exist then `get_option()` returns `false` which is the same falsy value as the stored `'0'`.
68+
*
69+
* @since n.e.x.t
70+
* @access private
71+
*
72+
* @return bool Whether unavailable.
73+
*/
74+
function od_is_rest_api_unavailable(): bool {
75+
return 1 === (int) get_option( 'od_rest_api_unavailable', '0' );
76+
}
77+
78+
/**
79+
* Tests availability of the Optimization Detective REST API endpoint.
80+
*
81+
* @since n.e.x.t
82+
* @access private
83+
*
84+
* @param array<string, mixed>|WP_Error $response REST API response.
85+
* @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result.
86+
*/
87+
function od_compose_site_health_result( $response ): array {
88+
$common_description_html = '<p>' . wp_kses(
89+
sprintf(
90+
/* translators: %s is the REST API endpoint */
91+
__( 'To collect URL Metrics from visitors the REST API must be available to unauthenticated users. Specifically, visitors must be able to perform a <code>POST</code> request to the <code>%s</code> endpoint.', 'optimization-detective' ),
92+
'/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE
93+
),
94+
array( 'code' => array() )
95+
) . '</p>';
96+
97+
$result = array(
98+
'label' => __( 'The Optimization Detective REST API endpoint is available', 'optimization-detective' ),
99+
'status' => 'good',
100+
'badge' => array(
101+
'label' => __( 'Optimization Detective', 'optimization-detective' ),
102+
'color' => 'blue',
103+
),
104+
'description' => $common_description_html . '<p><strong>' . esc_html__( 'This appears to be working properly.', 'optimization-detective' ) . '</strong></p>',
105+
'actions' => '',
106+
'test' => 'optimization_detective_rest_api',
107+
);
108+
109+
$error_label = __( 'The Optimization Detective REST API endpoint is unavailable', 'optimization-detective' );
110+
$error_description_html = '<p>' . esc_html__( 'You may have a plugin active or server configuration which restricts access to logged-in users. Unauthenticated access must be restored in order for Optimization Detective to work.', 'optimization-detective' ) . '</p>';
111+
112+
if ( is_wp_error( $response ) ) {
113+
$result['status'] = 'recommended';
114+
$result['label'] = $error_label;
115+
$result['description'] = $common_description_html . $error_description_html . '<p>' . wp_kses(
116+
sprintf(
117+
/* translators: %s is the error code */
118+
__( 'The REST API responded with the error code <code>%s</code> and the following error message:', 'optimization-detective' ),
119+
esc_html( (string) $response->get_error_code() )
120+
),
121+
array( 'code' => array() )
122+
) . '</p><blockquote>' . esc_html( $response->get_error_message() ) . '</blockquote>';
123+
} else {
124+
$code = wp_remote_retrieve_response_code( $response );
125+
$message = wp_remote_retrieve_response_message( $response );
126+
$body = wp_remote_retrieve_body( $response );
127+
$data = json_decode( $body, true );
128+
129+
$is_expected = (
130+
400 === $code &&
131+
isset( $data['code'], $data['data']['params'] ) &&
132+
'rest_missing_callback_param' === $data['code'] &&
133+
is_array( $data['data']['params'] ) &&
134+
count( $data['data']['params'] ) > 0
135+
);
136+
if ( ! $is_expected ) {
137+
$result['status'] = 'recommended';
138+
if ( 401 === $code ) {
139+
$result['label'] = __( 'The Optimization Detective REST API endpoint is unavailable to logged-out users', 'optimization-detective' );
140+
} else {
141+
$result['label'] = $error_label;
142+
}
143+
$result['description'] = $common_description_html . $error_description_html . '<p>' . wp_kses(
144+
sprintf(
145+
/* translators: %d is the HTTP status code, %s is the status header description */
146+
__( 'The REST API returned with an HTTP status of <code>%1$d %2$s</code>.', 'optimization-detective' ),
147+
$code,
148+
esc_html( $message )
149+
),
150+
array( 'code' => array() )
151+
) . '</p>';
152+
153+
if ( isset( $data['message'] ) && is_string( $data['message'] ) ) {
154+
$result['description'] .= '<blockquote>' . esc_html( $data['message'] ) . '</blockquote>';
155+
}
156+
157+
$result['description'] .= '<details><summary>' . esc_html__( 'Raw response:', 'optimization-detective' ) . '</summary><pre style="white-space: pre-wrap">' . esc_html( $body ) . '</pre></details>';
158+
}
159+
}
160+
return $result;
161+
}
162+
163+
/**
164+
* Gets the response to an Optimization Detective REST API store request to confirm it is available to unauthenticated requests.
165+
*
166+
* @since n.e.x.t
167+
* @access private
168+
*
169+
* @param bool $use_cached Whether to use a previous response cached in a transient.
170+
* @return array{ response: array{ code: int, message: string }, body: string }|WP_Error Response.
171+
*/
172+
function od_get_rest_api_health_check_response( bool $use_cached ) {
173+
$transient_key = 'od_rest_api_health_check_response';
174+
$response = $use_cached ? get_transient( $transient_key ) : false;
175+
if ( false !== $response ) {
176+
return $response;
177+
}
178+
$rest_url = get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE );
179+
$response = wp_remote_post(
180+
$rest_url,
181+
array(
182+
'headers' => array( 'Content-Type' => 'application/json' ),
183+
'sslverify' => false,
184+
)
185+
);
186+
187+
// This transient will be used when showing the admin notice with the plugin on the plugins screen.
188+
// The 1-day expiration allows for fresher content than the weekly check initiated by Site Health.
189+
set_transient( $transient_key, $response, DAY_IN_SECONDS );
190+
return $response;
191+
}
192+
193+
/**
194+
* Renders an admin notice if the REST API health check fails.
195+
*
196+
* @since n.e.x.t
197+
* @access private
198+
*
199+
* @param bool $in_plugin_row Whether the notice is to be printed in the plugin row.
200+
*/
201+
function od_maybe_render_rest_api_health_check_admin_notice( bool $in_plugin_row ): void {
202+
if ( ! od_is_rest_api_unavailable() ) {
203+
return;
204+
}
205+
206+
$response = od_get_rest_api_health_check_response( true );
207+
$result = od_compose_site_health_result( $response );
208+
if ( 'good' === $result['status'] ) {
209+
// There's a slight chance the DB option is stale in the initial if statement.
210+
return;
211+
}
212+
213+
$message = sprintf(
214+
$in_plugin_row
215+
? '<summary style="margin: 0.5em 0">%s %s</summary>'
216+
: '<p><strong>%s %s</strong></p>',
217+
esc_html__( 'Warning:', 'optimization-detective' ),
218+
esc_html( $result['label'] )
219+
);
220+
221+
$message .= $result['description']; // This has already gone through Kses.
222+
223+
if ( current_user_can( 'view_site_health_checks' ) ) {
224+
$site_health_message = wp_kses(
225+
sprintf(
226+
/* translators: %s is the URL to the Site Health admin screen */
227+
__( 'Please visit <a href="%s">Site Health</a> to re-check this once you believe you have resolved the issue.', 'optimization-detective' ),
228+
esc_url( admin_url( 'site-health.php' ) )
229+
),
230+
array( 'a' => array( 'href' => array() ) )
231+
);
232+
$message .= "<p><em>$site_health_message</em></p>";
233+
}
234+
235+
if ( $in_plugin_row ) {
236+
$message = "<details>$message</details>";
237+
}
238+
239+
wp_admin_notice(
240+
$message,
241+
array(
242+
'type' => 'warning',
243+
'additional_classes' => $in_plugin_row ? array( 'inline', 'notice-alt' ) : array(),
244+
'paragraph_wrap' => false,
245+
)
246+
);
247+
}
248+
249+
/**
250+
* Displays an admin notice on the plugin row if the REST API health check fails.
251+
*
252+
* @since n.e.x.t
253+
* @access private
254+
*
255+
* @param string $plugin_file Plugin file.
256+
*/
257+
function od_render_rest_api_health_check_admin_notice_in_plugin_row( string $plugin_file ): void {
258+
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used?
259+
return;
260+
}
261+
od_maybe_render_rest_api_health_check_admin_notice( true );
262+
}
263+
264+
/**
265+
* Runs the REST API health check if it hasn't been run yet.
266+
*
267+
* This happens at the `admin_init` action to avoid running the check on the frontend. This will run on the first admin
268+
* page load after the plugin has been activated. This allows for this function to add an action at `admin_notices` so
269+
* that an error message can be displayed after performing that plugin activation request. Note that a plugin activation
270+
* hook cannot be used for this purpose due to not being compatible with multisite. While the site health notice is
271+
* shown at the `admin_notices` action once, the notice will only be displayed inline with the plugin row thereafter
272+
* via {@see od_render_rest_api_health_check_admin_notice_in_plugin_row()}.
273+
*
274+
* @since n.e.x.t
275+
* @access private
276+
*/
277+
function od_maybe_run_rest_api_health_check(): void {
278+
// If the option already exists, then the REST API health check has already been performed.
279+
if ( false !== get_option( 'od_rest_api_unavailable' ) ) {
280+
return;
281+
}
282+
283+
// This will populate the od_rest_api_unavailable option so that the function won't execute on the next page load.
284+
if ( 'good' !== od_test_rest_api_availability()['status'] ) {
285+
// Show any notice in the main admin notices area for the first page load (e.g. after plugin activation).
286+
add_action(
287+
'admin_notices',
288+
static function (): void {
289+
od_maybe_render_rest_api_health_check_admin_notice( false );
290+
}
291+
);
292+
}
293+
}

plugins/optimization-detective/storage/rest-api.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
*
3535
* @since 0.1.0
3636
* @access private
37+
*
38+
* @see od_compose_site_health_result()
3739
*/
3840
function od_register_endpoint(): void {
3941

0 commit comments

Comments
 (0)