'https://hub.ai-o.com.au', 'connector_id' => '', 'connector_secret' => '', 'gsc_connected' => false, 'gsc_property' => '', 'gsc_last_sync' => '', 'gsc_last_error' => '', 'gsc_current_start' => '', 'gsc_current_end' => '', 'gsc_previous_start' => '', 'gsc_previous_end' => '', 'gsc_current_rows' => 0, 'gsc_previous_rows' => 0, 'ga4_property' => '', 'ga4_last_sync' => '', 'ga4_last_error' => '', ); } public static function settings() { return wp_parse_args( get_option( 'ai_o_optimiser_settings', array() ), self::default_settings() ); } /** * Whether the stored Hub address is the former default (ai-o.com.au). * * Read-side only: the stored value is kept as-is. The settings screen uses * this to say how to move the connection. * * @return bool */ public static function hub_is_former_authority() { $settings = self::settings(); $url = isset( $settings['connector_hub_url'] ) ? untrailingslashit( esc_url_raw( $settings['connector_hub_url'] ) ) : ''; return self::FORMER_HUB_URL === $url; } public static function update_settings( $changes ) { $settings = self::settings(); foreach ( $changes as $key => $value ) { $settings[ $key ] = $value; } update_option( 'ai_o_optimiser_settings', $settings ); } private static function ensure_identity() { $settings = self::settings(); if ( ! empty( $settings['connector_id'] ) && ! empty( $settings['connector_secret'] ) ) { $secret = self::decrypt_secret( $settings['connector_secret'] ); if ( ! is_wp_error( $secret ) && '' !== $secret ) { return array( 'connection_id' => $settings['connector_id'], 'secret' => $secret ); } } try { $plain = rtrim( strtr( base64_encode( random_bytes( 32 ) ), '+/', '-_' ), '=' ); } catch ( Throwable $e ) { return new WP_Error( 'ai_o_optimiser_connector_random', __( 'The server could not generate a secure connection secret.', 'ai-o-optimiser' ) ); } $encrypted = self::encrypt_secret( $plain ); if ( is_wp_error( $encrypted ) ) { return $encrypted; } $connection_id = wp_generate_uuid4(); self::update_settings( array( 'connector_id' => $connection_id, 'connector_secret' => $encrypted ) ); return array( 'connection_id' => $connection_id, 'secret' => $plain ); } public static function register_and_get_connect_url() { $identity = self::ensure_identity(); if ( is_wp_error( $identity ) ) { return $identity; } $hub = self::hub_url(); if ( is_wp_error( $hub ) ) { return $hub; } $body = wp_json_encode( array( 'connection_id' => $identity['connection_id'], 'site_name' => get_bloginfo( 'name' ), 'site_url' => home_url( '/' ), 'return_url' => admin_url( 'admin-post.php?action=ai_o_optimiser_gsc_oauth_return' ), 'secret' => $identity['secret'] ) ); $response = wp_safe_remote_post( $hub . '/wp-json/aio-connector/v1/register', array( 'timeout' => 20, 'redirection' => 2, 'limit_response_size' => 1048576, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json' ), 'body' => $body ) ); $data = self::decode_response( $response, __( 'The AI-O Connector Hub registration failed.', 'ai-o-optimiser' ) ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['configured'] ) ) { return new WP_Error( 'ai_o_optimiser_connector_not_configured', __( 'The AI-O Connector Hub is installed but its Google OAuth credentials are not configured yet.', 'ai-o-optimiser' ) ); } if ( empty( $data['connect_url'] ) || ! wp_http_validate_url( $data['connect_url'] ) ) { return new WP_Error( 'ai_o_optimiser_connector_bad_url', __( 'The Connector Hub did not return a valid Google connection URL.', 'ai-o-optimiser' ) ); } return $data['connect_url']; } /** Verify a provider/purpose-bound signed browser return from Connector Hub 1.4.4+. */ public static function verify_oauth_return( $args, $purpose = '' ) { $settings = self::settings(); $purpose = sanitize_key( (string) $purpose ); if ( '' === $purpose ) { $action = isset( $args['action'] ) ? sanitize_key( wp_unslash( $args['action'] ) ) : ''; $map = array( 'ai_o_optimiser_gsc_oauth_return' => 'gsc', 'ai_o_optimiser_ga4_data_oauth_return' => 'ga4_data', 'ai_o_optimiser_local_gbp_oauth_return' => 'gbp' ); $purpose = isset( $map[ $action ] ) ? $map[ $action ] : ''; } $connection_id = isset( $args['connection_id'] ) ? sanitize_text_field( wp_unslash( $args['connection_id'] ) ) : ''; $status = isset( $args['aioch_status'] ) ? sanitize_key( wp_unslash( $args['aioch_status'] ) ) : ''; $timestamp = isset( $args['aioch_ts'] ) ? (int) $args['aioch_ts'] : 0; $nonce = isset( $args['aioch_nonce'] ) ? sanitize_text_field( wp_unslash( $args['aioch_nonce'] ) ) : ''; $message = isset( $args['aioch_message'] ) ? sanitize_text_field( wp_unslash( $args['aioch_message'] ) ) : ''; $return_purpose = isset( $args['aioch_purpose'] ) ? sanitize_key( wp_unslash( $args['aioch_purpose'] ) ) : ''; $signature_v2 = isset( $args['aioch_sig_v2'] ) ? sanitize_text_field( wp_unslash( $args['aioch_sig_v2'] ) ) : ''; /* * Purpose binding is a security control: it stops a response issued for one * Google scope being accepted as authorisation for another. It is never * relaxed. But the failure message named neither side of the comparison, so * the only way to tell a Hub that sent no purpose from a Hub that sent the * wrong one was to read the redirect by hand. Both sides are reported now. * Purpose identifiers are routing labels, not credentials. */ if ( ! in_array( $purpose, array( 'gsc', 'ga4_data', 'gbp' ), true ) || ! hash_equals( $purpose, $return_purpose ) ) { if ( ! in_array( $purpose, array( 'gsc', 'ga4_data', 'gbp' ), true ) ) { return new WP_Error( 'ai_o_optimiser_connector_return_purpose', sprintf( /* translators: %s: the admin-post action the Connector Hub returned to. */ __( 'The Google connection response returned to an unrecognised endpoint (%s), so the plugin cannot tell which Google service it authorised.', 'ai-o-optimiser' ), '' !== $purpose ? $purpose : ( isset( $args['action'] ) ? sanitize_key( wp_unslash( $args['action'] ) ) : __( 'no action', 'ai-o-optimiser' ) ) ) ); } if ( '' === $return_purpose ) { return new WP_Error( 'ai_o_optimiser_connector_return_purpose', sprintf( /* translators: %s: the Google service this flow was for, e.g. gbp. */ __( 'The Connector Hub did not say which Google service this response was for. The %s flow needs Connector Hub 1.4.4 or later, which stamps a purpose on the response; an older Hub, or one where this provider flow predates that change, returns nothing here.', 'ai-o-optimiser' ), $purpose ) ); } return new WP_Error( 'ai_o_optimiser_connector_return_purpose', sprintf( /* translators: 1: purpose the plugin expected, 2: purpose the Connector Hub sent. */ __( 'The Google connection response was issued for a different provider flow: this site asked for %1$s and the Connector Hub returned %2$s. The Hub is sending the response to the wrong return URL, or labelling this flow with a different name.', 'ai-o-optimiser' ), $purpose, $return_purpose ) ); } if ( empty( $settings['connector_id'] ) || ! hash_equals( (string) $settings['connector_id'], $connection_id ) || abs( time() - $timestamp ) > 600 || ! preg_match( '/^[A-Za-z0-9_-]{12,80}$/', $nonce ) ) { return new WP_Error( 'ai_o_optimiser_connector_return_expired', __( 'The Google connection response is invalid or has expired.', 'ai-o-optimiser' ) ); } $secret = self::decrypt_secret( $settings['connector_secret'] ); if ( is_wp_error( $secret ) ) { return $secret; } $canonical = "oauth_return_v2\n{$purpose}\n{$connection_id}\n{$status}\n{$timestamp}\n{$nonce}\n" . hash( 'sha256', $message ); $expected = hash_hmac( 'sha256', $canonical, $secret ); if ( '' === $signature_v2 || ! hash_equals( $expected, $signature_v2 ) ) { return new WP_Error( 'ai_o_optimiser_connector_return_signature', __( 'The Google connection response could not be verified for this provider.', 'ai-o-optimiser' ) ); } $replay_key = 'ai_o_optimiser_oauth_return_' . $purpose . '_' . hash( 'sha256', $connection_id . '|' . $nonce ); if ( get_transient( $replay_key ) ) { return new WP_Error( 'ai_o_optimiser_connector_return_replay', __( 'This Google connection response has already been used.', 'ai-o-optimiser' ) ); } set_transient( $replay_key, 1, 15 * MINUTE_IN_SECONDS ); $result = array( 'status' => $status, 'message' => $message, 'purpose' => $purpose ); /** * Only verified, purpose-bound, signed and replay-safe returns reach this * boundary. Provider state consumers must never see an unverified result. */ do_action( 'ai_o_optimiser_connector_oauth_verified', $purpose, $result ); return $result; } public static function status() { return self::signed_request( '/status', array() ); } public static function properties() { $data = self::signed_request( '/properties', array() ); return is_wp_error( $data ) ? $data : ( isset( $data['properties'] ) && is_array( $data['properties'] ) ? $data['properties'] : array() ); } public static function query_search_console( $property, $start, $end, $row_limit = 25000, $start_row = 0 ) { return self::signed_request( '/search-console/query', array( 'property' => $property, 'start_date' => $start, 'end_date' => $end, 'dimensions' => array( 'query', 'page' ), 'row_limit' => min( 25000, max( 1, (int) $row_limit ) ), 'start_row' => max( 0, (int) $start_row ) ) ); } /** * Provider-neutral public web search for profile discovery. * * The current Connector Hub does not ship a general search provider. Keep the * transport contract dormant until a Hub/provider integration explicitly * supplies a same-Hub endpoint; do not generate predictable 404 traffic from * customer WordPress sites merely because Local discovery ran. */ public static function profile_search( $query, $identity = array(), $limit = 30 ) { $endpoint = apply_filters( 'ai_o_optimiser_connector_profile_search_endpoint', '' ); $endpoint = is_string( $endpoint ) ? trim( $endpoint ) : ''; if ( ! $endpoint || ! preg_match( '~^/[a-z0-9/_-]+$~i', $endpoint ) ) { return new WP_Error( 'ai_o_optimiser_profile_search_unavailable', __( 'Connector Hub profile search is not configured.', 'ai-o-optimiser' ) ); } return self::signed_request( $endpoint, array( 'query' => sanitize_text_field( (string) $query ), 'business_name' => sanitize_text_field( (string) ( $identity['business_name'] ?? '' ) ), 'locality' => sanitize_text_field( (string) ( $identity['locality'] ?? '' ) ), 'region' => sanitize_text_field( (string) ( $identity['region'] ?? '' ) ), 'country' => sanitize_text_field( (string) ( $identity['country'] ?? '' ) ), 'limit' => min( 50, max( 1, (int) $limit ) ), ) ); } public static function analytics_connect_url( $return_url ) { $settings = self::settings(); if ( empty( $settings['connector_id'] ) || empty( $settings['connector_secret'] ) ) { $registered = self::register_and_get_connect_url(); if ( is_wp_error( $registered ) ) { return $registered; } } $data = self::signed_request( '/analytics/connect-url', array( 'return_url' => esc_url_raw( $return_url ) ) ); if ( is_wp_error( $data ) ) { return $data; } if ( empty( $data['connect_url'] ) || ! wp_http_validate_url( $data['connect_url'] ) ) { return new WP_Error( 'ai_o_optimiser_ga4_connect_url', __( 'The Connector Hub did not return a valid GA4 authorisation URL.', 'ai-o-optimiser' ) ); } return $data['connect_url']; } public static function analytics_properties() { $data = self::signed_request( '/analytics/properties', array() ); return is_wp_error( $data ) ? $data : ( isset( $data['properties'] ) && is_array( $data['properties'] ) ? $data['properties'] : array() ); } public static function analytics_direct_impact( $property, $current_start, $current_end, $previous_start, $previous_end ) { return self::signed_request( '/analytics/direct-impact', array( 'property' => $property, 'current_start' => $current_start, 'current_end' => $current_end, 'previous_start' => $previous_start, 'previous_end' => $previous_end ) ); } public static function disconnect() { $data = self::signed_request( '/disconnect', array() ); self::update_settings( array( 'gsc_connected' => false, 'gsc_property' => '', 'gsc_last_sync' => '', 'gsc_last_error' => is_wp_error( $data ) ? sanitize_text_field( $data->get_error_message() ) : '', 'gsc_current_rows' => 0, 'gsc_previous_rows' => 0, 'ga4_property' => '', 'ga4_last_sync' => '', 'ga4_last_error' => '' ) ); return $data; } /** * The one signed-request boundary to the AI-O Connector Hub. * * Every AI-O component that needs the Hub goes through here rather than * rebuilding the HMAC/decrypt/transport primitive. Hand-rolled copies * drift: the Local GBP client previously carried its own and had lost * wp_safe_remote_post's SSRF protection plus the redirect and response * size caps. Keep this the only implementation. * * @param string $route Hub route below /aio-connector/v1, e.g. '/status'. * @param array $payload JSON-encoded request body. * @param string $fallback_message Error message when the Hub returns no usable one. * @return array|WP_Error Decoded Hub response. */ public static function hub_request( $route, $payload = array(), $fallback_message = '' ) { return self::signed_request( $route, $payload, $fallback_message ); } private static function signed_request( $route, $payload, $fallback_message = '' ) { $settings = self::settings(); if ( empty( $settings['connector_id'] ) || empty( $settings['connector_secret'] ) ) { return new WP_Error( 'ai_o_optimiser_connector_missing', __( 'This site has not been registered with the AI-O Connector Hub.', 'ai-o-optimiser' ) ); } $secret = self::decrypt_secret( $settings['connector_secret'] ); if ( is_wp_error( $secret ) ) { return $secret; } $hub = self::hub_url(); if ( is_wp_error( $hub ) ) { return $hub; } $body = wp_json_encode( $payload ); $timestamp = time(); $nonce = wp_generate_password( 24, false, false ); $rest_route = '/aio-connector/v1' . $route; $canonical = "POST\n{$rest_route}\n{$timestamp}\n{$nonce}\n" . hash( 'sha256', $body ); $signature = hash_hmac( 'sha256', $canonical, $secret ); $response = wp_safe_remote_post( $hub . '/wp-json' . $rest_route, array( 'timeout' => 35, 'redirection' => 2, 'limit_response_size' => 1048576, 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', 'X-AIO-Connection' => $settings['connector_id'], 'X-AIO-Timestamp' => $timestamp, 'X-AIO-Nonce' => $nonce, 'X-AIO-Signature' => $signature ), 'body' => $body ) ); $fallback = '' !== (string) $fallback_message ? (string) $fallback_message : __( 'The AI-O Connector Hub request failed.', 'ai-o-optimiser' ); return self::decode_response( $response, $fallback ); } private static function hub_url() { $settings = self::settings(); $url = isset( $settings['connector_hub_url'] ) ? untrailingslashit( esc_url_raw( $settings['connector_hub_url'] ) ) : ''; $url = apply_filters( 'ai_o_optimiser_connector_hub_url', $url ); if ( ! wp_http_validate_url( $url ) ) { return new WP_Error( 'ai_o_optimiser_connector_hub_url', __( 'The AI-O Connector Hub URL is invalid.', 'ai-o-optimiser' ) ); } $parts = wp_parse_url( $url ); $allow_http = (bool) apply_filters( 'ai_o_optimiser_connector_allow_insecure_hub', false, $url ); if ( ! $allow_http && ( empty( $parts['scheme'] ) || 'https' !== strtolower( $parts['scheme'] ) ) ) { return new WP_Error( 'ai_o_optimiser_connector_hub_https', __( 'The AI-O Connector Hub must use HTTPS.', 'ai-o-optimiser' ) ); } return $url; } private static function decode_response( $response, $fallback ) { if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( $code < 200 || $code >= 300 ) { $message = $fallback; if ( is_array( $data ) && ! empty( $data['message'] ) ) { $message = $data['message']; } return new WP_Error( 'ai_o_optimiser_connector_http', sanitize_text_field( $message ), array( 'status' => $code ) ); } return is_array( $data ) ? $data : new WP_Error( 'ai_o_optimiser_connector_json', __( 'The AI-O Connector Hub returned an unreadable response.', 'ai-o-optimiser' ) ); } private static function encryption_key() { $material = defined( 'AUTH_KEY' ) && AUTH_KEY ? AUTH_KEY : wp_salt( 'auth' ); return hash( 'sha256', $material, true ); } private static function encrypt_secret( $plain ) { if ( function_exists( 'sodium_crypto_secretbox' ) ) { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); return self::SECRET_PREFIX . 's:' . base64_encode( $nonce . sodium_crypto_secretbox( $plain, $nonce, self::encryption_key() ) ); } if ( function_exists( 'openssl_encrypt' ) ) { $iv = random_bytes( 12 ); $tag = ''; $cipher = openssl_encrypt( $plain, 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, $iv, $tag ); if ( false !== $cipher ) { return self::SECRET_PREFIX . 'g:' . base64_encode( $iv . $tag . $cipher ); } } return new WP_Error( 'ai_o_optimiser_connector_crypto', __( 'The server needs Sodium or OpenSSL before a secure Connector Hub secret can be stored.', 'ai-o-optimiser' ) ); } private static function decrypt_secret( $stored ) { if ( 0 !== strpos( (string) $stored, self::SECRET_PREFIX ) ) { return new WP_Error( 'ai_o_optimiser_connector_secret_format', __( 'The saved Connector Hub secret is invalid. Reconnect the site.', 'ai-o-optimiser' ) ); } $payload = substr( $stored, strlen( self::SECRET_PREFIX ) ); $mode = substr( $payload, 0, 2 ); $raw = base64_decode( substr( $payload, 2 ), true ); if ( false === $raw ) { return new WP_Error( 'ai_o_optimiser_connector_secret_format', __( 'The saved Connector Hub secret is corrupted. Reconnect the site.', 'ai-o-optimiser' ) ); } if ( 's:' === $mode && function_exists( 'sodium_crypto_secretbox_open' ) ) { $n = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES; $plain = strlen( $raw ) > $n ? sodium_crypto_secretbox_open( substr( $raw, $n ), substr( $raw, 0, $n ), self::encryption_key() ) : false; return false === $plain ? new WP_Error( 'ai_o_optimiser_connector_secret_decrypt', __( 'The saved Connector Hub secret could not be decrypted. Reconnect the site.', 'ai-o-optimiser' ) ) : $plain; } if ( 'g:' === $mode && function_exists( 'openssl_decrypt' ) && strlen( $raw ) > 28 ) { $plain = openssl_decrypt( substr( $raw, 28 ), 'aes-256-gcm', self::encryption_key(), OPENSSL_RAW_DATA, substr( $raw, 0, 12 ), substr( $raw, 12, 16 ) ); return false === $plain ? new WP_Error( 'ai_o_optimiser_connector_secret_decrypt', __( 'The saved Connector Hub secret could not be decrypted. Reconnect the site.', 'ai-o-optimiser' ) ) : $plain; } return new WP_Error( 'ai_o_optimiser_connector_crypto', __( 'The encryption library needed to read the Connector Hub secret is unavailable.', 'ai-o-optimiser' ) ); } }