-
Notifications
You must be signed in to change notification settings - Fork 107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add site health check to detect blocked REST API and short-circuit optimization when unavailable #1762
Add site health check to detect blocked REST API and short-circuit optimization when unavailable #1762
Changes from all commits
88952a5
4bff9fe
56d769a
ba911e1
12a229c
a0f460d
c29cb7c
8d8ae3e
422a5bf
0bc74f7
36042fd
ad21df8
0bba468
e1b9004
dbede65
2b2ca3e
e7c1001
7e4e057
a2baa65
e1ec10c
48c83a5
8895d35
2fbd530
5949bc9
1aa82b4
98fff4d
c095436
ebcd804
57f7566
226584f
1855ecb
0ad417a
7ad36cd
d957745
4c1b2dc
7f76d31
a0cce21
22dde1b
9869150
55e3b55
85b13f4
51a56ba
2a132ca
e22bb84
7eda72c
cc16600
523daaa
a1335e0
e5d3f72
c6b4417
3212dae
19ff8e4
546217f
3d5e8a8
c3b682f
c085ff0
50a1898
c7d31a7
85da162
0772ebe
dbff1f7
0693086
8926f10
6fee919
ddc6164
b4736a4
02953c7
14e9471
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,293 @@ | ||
<?php | ||
/** | ||
* Site Health checks. | ||
* | ||
* @package optimization-detective | ||
* @since n.e.x.t | ||
*/ | ||
|
||
if ( ! defined( 'ABSPATH' ) ) { | ||
exit; // Exit if accessed directly. | ||
} | ||
|
||
/** | ||
* Adds the Optimization Detective REST API check to site health tests. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param array{direct: array<string, array{label: string, test: string}>}|mixed $tests Site Health Tests. | ||
* @return array{direct: array<string, array{label: string, test: string}>} Amended tests. | ||
*/ | ||
function od_add_rest_api_availability_test( $tests ): array { | ||
if ( ! is_array( $tests ) ) { | ||
$tests = array(); | ||
} | ||
$tests['direct']['optimization_detective_rest_api'] = array( | ||
'label' => __( 'Optimization Detective REST API Endpoint Availability', 'optimization-detective' ), | ||
'test' => static function () { | ||
// Note: A closure is used here to improve symbol discovery for the sake of potential refactoring. | ||
return od_test_rest_api_availability(); | ||
}, | ||
); | ||
|
||
return $tests; | ||
} | ||
|
||
/** | ||
* Tests availability of the Optimization Detective REST API endpoint. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result. | ||
*/ | ||
function od_test_rest_api_availability(): array { | ||
$response = od_get_rest_api_health_check_response( false ); | ||
$result = od_compose_site_health_result( $response ); | ||
$is_unavailable = 'good' !== $result['status']; | ||
update_option( | ||
'od_rest_api_unavailable', | ||
$is_unavailable ? '1' : '0', | ||
true // Intentionally autoloaded since used on every frontend request. | ||
); | ||
return $result; | ||
} | ||
|
||
/** | ||
* Checks whether the Optimization Detective REST API endpoint is unavailable. | ||
* | ||
* This merely checks the database option what was previously computed in the Site Health test as done in {@see od_test_rest_api_availability()}. | ||
* This is to avoid checking for REST API availability during a frontend request. Note that when the plugin is first | ||
* installed, the 'od_rest_api_unavailable' option will not be in the database, as the check has not been performed | ||
* yet. Once Site Health's weekly check happens or when a user accesses the admin so that the admin_init action fires, | ||
* then at this point the check will be performed at {@see od_maybe_run_rest_api_health_check()}. In practice, this will | ||
* happen immediately after the user activates a plugin since the user is redirected back to the plugin list table in | ||
* the admin. The reason for storing the negative unavailable state as opposed to the positive available state is that | ||
* when an option does not exist then `get_option()` returns `false` which is the same falsy value as the stored `'0'`. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @return bool Whether unavailable. | ||
*/ | ||
function od_is_rest_api_unavailable(): bool { | ||
return 1 === (int) get_option( 'od_rest_api_unavailable', '0' ); | ||
} | ||
|
||
/** | ||
* Tests availability of the Optimization Detective REST API endpoint. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param array<string, mixed>|WP_Error $response REST API response. | ||
* @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Result. | ||
*/ | ||
function od_compose_site_health_result( $response ): array { | ||
$common_description_html = '<p>' . wp_kses( | ||
sprintf( | ||
/* translators: %s is the REST API endpoint */ | ||
__( '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' ), | ||
'/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE | ||
), | ||
array( 'code' => array() ) | ||
) . '</p>'; | ||
|
||
$result = array( | ||
'label' => __( 'The Optimization Detective REST API endpoint is available', 'optimization-detective' ), | ||
'status' => 'good', | ||
'badge' => array( | ||
'label' => __( 'Optimization Detective', 'optimization-detective' ), | ||
'color' => 'blue', | ||
), | ||
'description' => $common_description_html . '<p><strong>' . esc_html__( 'This appears to be working properly.', 'optimization-detective' ) . '</strong></p>', | ||
'actions' => '', | ||
'test' => 'optimization_detective_rest_api', | ||
); | ||
|
||
$error_label = __( 'The Optimization Detective REST API endpoint is unavailable', 'optimization-detective' ); | ||
$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>'; | ||
|
||
if ( is_wp_error( $response ) ) { | ||
$result['status'] = 'recommended'; | ||
$result['label'] = $error_label; | ||
$result['description'] = $common_description_html . $error_description_html . '<p>' . wp_kses( | ||
sprintf( | ||
/* translators: %s is the error code */ | ||
__( 'The REST API responded with the error code <code>%s</code> and the following error message:', 'optimization-detective' ), | ||
esc_html( (string) $response->get_error_code() ) | ||
), | ||
array( 'code' => array() ) | ||
) . '</p><blockquote>' . esc_html( $response->get_error_message() ) . '</blockquote>'; | ||
} else { | ||
$code = wp_remote_retrieve_response_code( $response ); | ||
$message = wp_remote_retrieve_response_message( $response ); | ||
$body = wp_remote_retrieve_body( $response ); | ||
$data = json_decode( $body, true ); | ||
|
||
$is_expected = ( | ||
400 === $code && | ||
isset( $data['code'], $data['data']['params'] ) && | ||
'rest_missing_callback_param' === $data['code'] && | ||
is_array( $data['data']['params'] ) && | ||
count( $data['data']['params'] ) > 0 | ||
); | ||
if ( ! $is_expected ) { | ||
$result['status'] = 'recommended'; | ||
if ( 401 === $code ) { | ||
$result['label'] = __( 'The Optimization Detective REST API endpoint is unavailable to logged-out users', 'optimization-detective' ); | ||
} else { | ||
$result['label'] = $error_label; | ||
} | ||
$result['description'] = $common_description_html . $error_description_html . '<p>' . wp_kses( | ||
sprintf( | ||
/* translators: %d is the HTTP status code, %s is the status header description */ | ||
__( 'The REST API returned with an HTTP status of <code>%1$d %2$s</code>.', 'optimization-detective' ), | ||
$code, | ||
esc_html( $message ) | ||
), | ||
array( 'code' => array() ) | ||
) . '</p>'; | ||
|
||
if ( isset( $data['message'] ) && is_string( $data['message'] ) ) { | ||
$result['description'] .= '<blockquote>' . esc_html( $data['message'] ) . '</blockquote>'; | ||
} | ||
|
||
$result['description'] .= '<details><summary>' . esc_html__( 'Raw response:', 'optimization-detective' ) . '</summary><pre style="white-space: pre-wrap">' . esc_html( $body ) . '</pre></details>'; | ||
} | ||
} | ||
return $result; | ||
} | ||
|
||
/** | ||
* Gets the response to an Optimization Detective REST API store request to confirm it is available to unauthenticated requests. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param bool $use_cached Whether to use a previous response cached in a transient. | ||
* @return array{ response: array{ code: int, message: string }, body: string }|WP_Error Response. | ||
*/ | ||
function od_get_rest_api_health_check_response( bool $use_cached ) { | ||
$transient_key = 'od_rest_api_health_check_response'; | ||
$response = $use_cached ? get_transient( $transient_key ) : false; | ||
if ( false !== $response ) { | ||
return $response; | ||
} | ||
$rest_url = get_rest_url( null, OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ); | ||
$response = wp_remote_post( | ||
$rest_url, | ||
array( | ||
'headers' => array( 'Content-Type' => 'application/json' ), | ||
'sslverify' => false, | ||
) | ||
); | ||
|
||
// This transient will be used when showing the admin notice with the plugin on the plugins screen. | ||
// The 1-day expiration allows for fresher content than the weekly check initiated by Site Health. | ||
set_transient( $transient_key, $response, DAY_IN_SECONDS ); | ||
Comment on lines
+179
to
+189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit concerned about trying to store a potential There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is it concerning to store a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, is there prior art for that in WordPress? I suppose it could work, but have you tested this, both using the DB for transients as well as a persistent object cache? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As noted above, the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Take a look at FWIW, I've been aware of storing class instances in transients and options since before I can remember. What's the difference with arrays? Both require serialization. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was googling for other examples, and I found a reference to Site Kit storing objects in transients: https://wpengine.com/resources/guide-to-transients-in-wordpress/ I can't find the current reference to that code in GitHub though. Here's an example in the Transients documentation about storing a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's another example where an object is stored in a transient in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Objects are also stored in object cache in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, you have me convinced. With this already done elsewhere in Core, this shouldn't be an issue, and the |
||
return $response; | ||
} | ||
|
||
/** | ||
* Renders an admin notice if the REST API health check fails. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param bool $in_plugin_row Whether the notice is to be printed in the plugin row. | ||
*/ | ||
function od_maybe_render_rest_api_health_check_admin_notice( bool $in_plugin_row ): void { | ||
if ( ! od_is_rest_api_unavailable() ) { | ||
return; | ||
} | ||
|
||
$response = od_get_rest_api_health_check_response( true ); | ||
$result = od_compose_site_health_result( $response ); | ||
if ( 'good' === $result['status'] ) { | ||
// There's a slight chance the DB option is stale in the initial if statement. | ||
return; | ||
} | ||
|
||
$message = sprintf( | ||
$in_plugin_row | ||
? '<summary style="margin: 0.5em 0">%s %s</summary>' | ||
: '<p><strong>%s %s</strong></p>', | ||
esc_html__( 'Warning:', 'optimization-detective' ), | ||
esc_html( $result['label'] ) | ||
); | ||
|
||
$message .= $result['description']; // This has already gone through Kses. | ||
|
||
if ( current_user_can( 'view_site_health_checks' ) ) { | ||
$site_health_message = wp_kses( | ||
sprintf( | ||
/* translators: %s is the URL to the Site Health admin screen */ | ||
__( 'Please visit <a href="%s">Site Health</a> to re-check this once you believe you have resolved the issue.', 'optimization-detective' ), | ||
esc_url( admin_url( 'site-health.php' ) ) | ||
), | ||
array( 'a' => array( 'href' => array() ) ) | ||
); | ||
$message .= "<p><em>$site_health_message</em></p>"; | ||
} | ||
|
||
if ( $in_plugin_row ) { | ||
$message = "<details>$message</details>"; | ||
} | ||
|
||
wp_admin_notice( | ||
$message, | ||
array( | ||
'type' => 'warning', | ||
'additional_classes' => $in_plugin_row ? array( 'inline', 'notice-alt' ) : array(), | ||
'paragraph_wrap' => false, | ||
) | ||
); | ||
} | ||
|
||
/** | ||
* Displays an admin notice on the plugin row if the REST API health check fails. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
* | ||
* @param string $plugin_file Plugin file. | ||
*/ | ||
function od_render_rest_api_health_check_admin_notice_in_plugin_row( string $plugin_file ): void { | ||
if ( 'optimization-detective/load.php' !== $plugin_file ) { // TODO: What if a different plugin slug is used? | ||
felixarntz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return; | ||
} | ||
od_maybe_render_rest_api_health_check_admin_notice( true ); | ||
} | ||
|
||
/** | ||
* Runs the REST API health check if it hasn't been run yet. | ||
* | ||
* This happens at the `admin_init` action to avoid running the check on the frontend. This will run on the first admin | ||
* page load after the plugin has been activated. This allows for this function to add an action at `admin_notices` so | ||
* that an error message can be displayed after performing that plugin activation request. Note that a plugin activation | ||
* hook cannot be used for this purpose due to not being compatible with multisite. While the site health notice is | ||
* shown at the `admin_notices` action once, the notice will only be displayed inline with the plugin row thereafter | ||
* via {@see od_render_rest_api_health_check_admin_notice_in_plugin_row()}. | ||
* | ||
* @since n.e.x.t | ||
* @access private | ||
*/ | ||
function od_maybe_run_rest_api_health_check(): void { | ||
// If the option already exists, then the REST API health check has already been performed. | ||
if ( false !== get_option( 'od_rest_api_unavailable' ) ) { | ||
return; | ||
} | ||
|
||
// This will populate the od_rest_api_unavailable option so that the function won't execute on the next page load. | ||
if ( 'good' !== od_test_rest_api_availability()['status'] ) { | ||
// Show any notice in the main admin notices area for the first page load (e.g. after plugin activation). | ||
add_action( | ||
'admin_notices', | ||
static function (): void { | ||
od_maybe_render_rest_api_health_check_admin_notice( false ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, the rest case should actually run the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 6fee919 |
||
} | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like I neglected to include a test for the cached state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See 6fee919