|WP_Error Decoded body, or an error. */ function citolume_api_get( $path ) { $key = citolume_license_key(); if ( '' === $key ) { return new WP_Error( 'citolume_no_key', __( 'No licence key saved yet.', 'citolume' ) ); } return citolume_api_read( wp_remote_get( citolume_api_url() . $path, array( 'timeout' => CITOLUME_TIMEOUT, 'headers' => array( 'X-License-Key' => $key, 'Accept' => 'application/json', ), ) ) ); } /** * The plan and entitlements this licence key carries. * * Cached, because it is read every time the dashboard renders and it changes * only when somebody changes their plan. * * @param bool $force Skip the cache. * @return array|WP_Error */ function citolume_site_status( $force = false ) { $cached = get_transient( 'citolume_site_status' ); if ( ! $force && is_array( $cached ) ) { return $cached; } $status = citolume_api_get( '/v1/site' ); if ( is_wp_error( $status ) ) { // Deliberately not cached: a transient failure should not leave the // screen claiming "disconnected" for ten minutes after it recovers. return $status; } set_transient( 'citolume_site_status', $status, CITOLUME_STATUS_TTL ); // Kept apart from the transient for the Plugins screen's Deactivate // notice (ticket 146), which must not call the service from a screen // that is not ours, and must still know after the transient expires. $note = ! empty( $status['renews'] ) ? (string) ( $status['plan'] ?? '' ) : ''; if ( get_option( 'citolume_billing_note', '' ) !== $note ) { update_option( 'citolume_billing_note', $note, false ); } return $status; } /** * Forgets the cached status. * * @return void */ function citolume_clear_status_cache() { delete_transient( 'citolume_site_status' ); // The answer panel and the readiness reading are keyed to the same site; // a key change or a reconnect makes both of them somebody else's data. delete_transient( 'citolume_answers' ); delete_transient( 'citolume_readiness' ); } /** * Whether this install has a working connection. * * @return bool */ function citolume_is_connected() { return ! is_wp_error( citolume_site_status() ); } /** * Calls the API with a body, for anything that changes something. * * @param string $path Path beginning with a slash. * @param string $method HTTP method. * @param array $body Payload, omitted when empty. * @param int $timeout Seconds; only calls that do slow work on the service pass more. * @return array|WP_Error Decoded body, or an error. */ function citolume_api_send( $path, $method = 'POST', $body = array(), $timeout = CITOLUME_TIMEOUT ) { $key = citolume_license_key(); if ( '' === $key ) { return new WP_Error( 'citolume_no_key', __( 'No licence key saved yet.', 'citolume' ) ); } $args = array( 'method' => $method, 'timeout' => $timeout, 'headers' => array( 'X-License-Key' => $key, 'Accept' => 'application/json', ), ); if ( $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } return citolume_api_read( wp_remote_request( citolume_api_url() . $path, $args ) ); } /** * Turns a response into a body or a WP_Error. * * Shared so a GET and a POST fail in the same words. Extracted from * `citolume_api_get()` when the dashboard needed to write as well as read. * * @param array|WP_Error $response Raw wp_remote_* result. * @return array|WP_Error */ function citolume_api_read( $response ) { if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 401 === $code ) { return new WP_Error( 'citolume_bad_key', __( 'That licence key was not recognised.', 'citolume' ) ); } if ( $code < 200 || $code >= 300 ) { $detail = is_array( $body ) && isset( $body['detail'] ) ? (string) $body['detail'] : ''; return new WP_Error( 'citolume_http_error', $detail ? $detail : sprintf( /* translators: %d: HTTP status code. */ __( 'The service replied with an error (%d).', 'citolume' ), (int) $code ) ); } // A 204 has no body by design — DELETE answers that way. Demanding one // reported a successful deletion as a failure, which is worse than the // reverse: the question really was gone, and the screen said it was not. if ( 204 === $code || '' === trim( (string) wp_remote_retrieve_body( $response ) ) ) { return array(); } if ( ! is_array( $body ) ) { return new WP_Error( 'citolume_bad_body', __( 'The service sent a reply we could not read.', 'citolume' ) ); } return $body; } /** * The latest finished scan, with its per-engine detail. * * Not cached. A customer pressing "Scan now" and then reloading must see the * new result, and this is only fetched on our own screen. * * @return array|WP_Error */ function citolume_results() { return citolume_api_get( '/v1/results' ); } /** * The service's half of the funnel: mentioned and cited stages, per-engine * chips, freshness, and the first-citation stamp. The plugin adds the * reachable and crawled stages from its own local data — by design, so the * free features never depend on the service. * * @return array|WP_Error */ function citolume_funnel() { return citolume_api_get( '/v1/funnel' ); } /** * Who owns your questions: cited hosts across the latest run, grouped, * counted once per question. * * @return array|WP_Error */ function citolume_sources() { return citolume_api_get( '/v1/sources' ); } /** * The tracked questions. * * Returns a bare list, not an object with a `queries` key. * * @return array>|WP_Error */ function citolume_queries() { return citolume_api_get( '/v1/queries' ); } /** * Adds a tracked question. * * @param string $text The question. * @return array|WP_Error */ function citolume_add_query( $text ) { return citolume_api_send( '/v1/queries', 'POST', array( 'text' => $text ) ); } /** * Removes a tracked question. * * @param int $id Query id. * @return array|WP_Error */ function citolume_delete_query( $id ) { return citolume_api_send( '/v1/queries/' . (int) $id, 'DELETE' ); } /** * Starts a scan. * * Returns immediately with a run id — the scan itself takes minutes, so it runs * on the API's side and the screen polls `citolume_run()` for the result. * * @return array|WP_Error */ function citolume_start_scan() { return citolume_api_send( '/v1/checks/run', 'POST' ); } /** * Claims the instant first scan after the wizard adds questions. * * Granted once per domain, ever — the API holds the latch, because the * plugin can be wiped and reinstalled. A spent grant answers with the * existing history and the next scheduled date instead of a scan, and the * wizard's Done step says so honestly. * * @return array|WP_Error */ function citolume_start_first_scan() { return citolume_api_send( '/v1/checks/first', 'POST' ); } /** * Polls one scan. * * @param int $run_id Run id from `citolume_start_scan()`. * @return array|WP_Error */ function citolume_run( $run_id ) { return citolume_api_get( '/v1/checks/' . (int) $run_id ); } /** * Calls an endpoint that deliberately has no licence key. * * The claim flow runs before the key exists — that is its whole point — so * this sends no `X-License-Key`. The generous timeout is for `/v1/claim/finish`, * which fetches this site back over the public internet before answering. * * @param string $path Path beginning with a slash. * @param array $body Payload. * @return array|WP_Error */ function citolume_api_public_post( $path, $body ) { return citolume_api_read( wp_remote_post( citolume_api_url() . $path, array( 'timeout' => 45, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), ) ) ); } /** * The signed billing-portal URL for this site's subscription. * * Requested only at click time: the URL expires within about a day, so * fetching it on page load would hand the customer a link that may already be * dead by the time they use it. * * @return string|WP_Error */ function citolume_portal_url() { $response = citolume_api_get( '/v1/subscription/portal' ); if ( is_wp_error( $response ) ) { return $response; } if ( empty( $response['url'] ) ) { return new WP_Error( 'citolume_bad_body', __( 'The service sent a reply we could not read.', 'citolume' ) ); } return (string) $response['url']; }