false, 'status' => 0, 'data' => array(), 'message' => __( 'Connect the site on the Account screen first.', 'astute-speed' ), ); } $args = array( 'method' => $method, 'timeout' => $timeout, 'user-agent' => 'AstuteSpeed/' . ASTSP_VERSION . '; ' . home_url( '/' ), 'headers' => array( 'Accept' => 'application/json' ), ); if ( astsp_service_connected() ) { $args['headers']['Authorization'] = 'Bearer ' . astsp_service_key(); } if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $response = wp_remote_request( astsp_service_url() . $path, $args ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'status' => 0, 'data' => array(), /* translators: %s: the connection error. */ 'message' => sprintf( __( 'The speed service could not be reached (%s). Try again in a few minutes.', 'astute-speed' ), $response->get_error_message() ), ); } return astsp_service_read( (int) wp_remote_retrieve_response_code( $response ), (string) wp_remote_retrieve_body( $response ) ); } /** * Whether the plugin may contact the speed service. Pure. * * Nothing is sent until the site owner connects on the Account screen, and * nothing after they disconnect: the only call allowed without a connection is * the Connect request itself. * * @param string $method HTTP method. * @param string $path Path on the service. * @param bool $connected Whether the site is connected. * @return bool */ function astsp_service_may_call( $method, $path, $connected ) { return $connected || ( 'POST' === strtoupper( (string) $method ) && '/sites' === $path ); } /** * The service's answer, read. Pure. * * @param int $status HTTP status. * @param string $body Response body. * @return array{ok: bool, status: int, data: array, message: string} */ function astsp_service_read( $status, $body ) { $data = json_decode( $body, true ); $data = is_array( $data ) ? $data : array(); $ok = $status >= 200 && $status < 300; if ( $ok ) { $message = ''; } elseif ( isset( $data['message'] ) && is_string( $data['message'] ) ) { $message = $data['message']; } elseif ( 401 === $status ) { $message = __( 'The speed service no longer recognises this site. Connect it again on the Account screen.', 'astute-speed' ); } else { /* translators: %d: HTTP status code. */ $message = sprintf( __( 'The speed service answered with an error (%d). Try again in a few minutes.', 'astute-speed' ), $status ); } return array( 'ok' => $ok, 'status' => $status, 'data' => $data, 'message' => wp_strip_all_tags( $message ), ); } /** * What the connect request tells the service: the site's address and the * versions that decide how its pages render. Nothing about visitors or users. * * @return array */ function astsp_service_site_info() { global $wp_version; return array( 'site_url' => home_url( '/' ), 'plugin_version' => ASTSP_VERSION, 'wp_version' => (string) $wp_version, 'php_version' => PHP_VERSION, ); } /** * Connects the site and keeps its key. Only called from the Account screen, * after the administrator has agreed. * * @return array{ok: bool, status: int, data: array, message: string} */ function astsp_service_connect() { // A one-time token proves this request comes from the site it names: the // service reads its hash back from astute-speed/v1/verify before issuing a key. $token = wp_generate_password( 48, false ); set_transient( 'astsp_verify_token', $token, 10 * MINUTE_IN_SECONDS ); $result = astsp_service_request( 'POST', '/sites', astsp_service_site_info() + array( 'token' => $token ), 45 ); delete_transient( 'astsp_verify_token' ); if ( $result['ok'] ) { $key = (string) ( $result['data']['key'] ?? '' ); if ( ! astsp_service_key_valid( $key ) ) { $result['ok'] = false; $result['message'] = __( 'The speed service sent back something that is not a site key. Try again in a few minutes.', 'astute-speed' ); return $result; } update_option( 'astsp_service_key', $key, false ); update_option( 'astsp_service_connected_at', gmdate( 'Y-m-d H:i:s' ), false ); delete_transient( 'astsp_service_status' ); } return $result; } /** * Disconnects: tells the service, then forgets the key either way. * * Anything still waiting on the service (a critical CSS job) is stopped by * astsp_service_forget(), so no scheduled check contacts it afterwards. * * @return void */ function astsp_service_disconnect() { if ( astsp_service_connected() ) { astsp_service_request( 'DELETE', '/sites/me', null, 15 ); } astsp_service_forget(); } /** * Forgets the connection and stops everything that would contact the service. * * @return void */ function astsp_service_forget() { delete_option( 'astsp_service_key' ); delete_option( 'astsp_service_connected_at' ); delete_transient( 'astsp_service_status' ); /** * Fires when the site is no longer connected to the speed service, so * modules can cancel work that would contact it. */ do_action( 'astsp_service_disconnected' ); } /** * The site's plan and what it has used this month, cached for ten minutes. * * @param bool $fresh Ask the service even when a cached answer exists. * @return array{plan?: string, page_types?: string[], limits?: array, used?: array, resets_at?: string, upgrade_url?: string, error?: string} */ function astsp_service_status( $fresh = false ) { if ( ! astsp_service_connected() ) { return array(); } $cached = get_transient( 'astsp_service_status' ); if ( ! $fresh && is_array( $cached ) ) { return $cached; } $result = astsp_service_request( 'GET', '/sites/me', null, 15 ); if ( ! $result['ok'] ) { if ( 401 === $result['status'] ) { astsp_service_forget(); } return array( 'error' => $result['message'] ); } set_transient( 'astsp_service_status', $result['data'], 10 * MINUTE_IN_SECONDS ); return $result['data']; } /** * Where a site owner upgrades: the address the service gave, or the plugin's page. * * @return string */ function astsp_upgrade_url() { $status = astsp_service_status(); $url = (string) ( $status['upgrade_url'] ?? '' ); if ( '' !== $url ) { return $url; } return add_query_arg( 'site', rawurlencode( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ), 'https://writeandgrow.com/account/astute-speed' ); } /** * One allowance line, e.g. "Critical CSS runs: 0 of 1 used". Pure. * * @param string $label Label. * @param mixed $used Used this month. * @param mixed $limit Monthly allowance; null when unlimited. * @return string */ function astsp_allowance_line( $label, $used, $limit ) { if ( null === $limit ) { /* translators: 1: allowance name, 2: number used. */ return sprintf( __( '%1$s: %2$d used, no limit', 'astute-speed' ), $label, (int) $used ); } /* translators: 1: allowance name, 2: number used, 3: monthly allowance. */ return sprintf( __( '%1$s: %2$d of %3$d used', 'astute-speed' ), $label, (int) $used, (int) $limit ); } /** * The route the service reads while a connect is under way, to check that the * request came from this site. Public and read-only: it answers with the * SHA-256 of the one-time token, and only during the few seconds a connect * takes; the rest of the time it answers 404. * * @return void */ function astsp_service_register_verify_route() { register_rest_route( 'astute-speed/v1', '/verify', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => static function () { $token = (string) get_transient( 'astsp_verify_token' ); if ( '' === $token ) { return new WP_REST_Response( array( 'message' => 'No connection is being made.' ), 404 ); } return rest_ensure_response( array( 'token_hash' => hash( 'sha256', $token ) ) ); }, ) ); } add_action( 'rest_api_init', 'astsp_service_register_verify_route' );