|
| 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 | +} |
0 commit comments