site_key(); } /** * Saves a new connection from the key exchange. * * @param array $data Response of POST /wordpress/token. */ public function save_connection( array $data ): bool { $key = isset( $data['site_key'] ) ? (string) $data['site_key'] : ''; if ( ! preg_match( '/^cs_wp_[0-9a-f]{64}$/', $key ) ) { return false; } if ( ! $this->store_key( $key ) ) { return false; } $tracking = isset( $data['tracking_key'] ) ? (string) $data['tracking_key'] : ''; if ( preg_match( '/^[A-Za-z0-9_-]{6,64}$/', $tracking ) ) { update_option( self::TRACKING_OPTION, $tracking, false ); } delete_option( self::ACCOUNT_OPTION ); $this->refresh_account(); return true; } /** Forgets the connection here, after telling CapyScout (best effort). */ public function disconnect(): void { if ( $this->is_connected() ) { $this->request( 'POST', '/api/wp/v1/disconnect', array(), 5 ); } $this->forget(); } /** Forgets the connection locally (the key stopped working, or the admin disconnected). */ public function forget(): void { delete_option( self::KEY_OPTION ); delete_option( self::ACCOUNT_OPTION ); delete_option( self::TRACKING_OPTION ); foreach ( array( 'capyscout_visitors_7', 'capyscout_visitors_30', 'capyscout_traffic_7', 'capyscout_traffic_30', 'capyscout_traffic_90', 'capyscout_campaigns', 'capyscout_lists' ) as $t ) { delete_transient( $t ); } } /** The cached account status (from /me), refreshed when older than $max_age seconds. */ public function account( int $max_age = 3600 ): array { $acc = get_option( self::ACCOUNT_OPTION, array() ); if ( ! is_array( $acc ) ) { $acc = array(); } if ( $this->is_connected() && ( empty( $acc['checked'] ) || time() - (int) $acc['checked'] > $max_age ) ) { $fresh = $this->refresh_account(); if ( null !== $fresh ) { $acc = $fresh; } } return $acc; } /** Reads /me and caches it. Null when CapyScout could not be reached. */ public function refresh_account(): ?array { $res = $this->request( 'GET', '/api/wp/v1/me' ); if ( 200 !== $res['status'] || ! is_array( $res['body'] ) ) { return null; } $ws = isset( $res['body']['workspace'] ) && is_array( $res['body']['workspace'] ) ? $res['body']['workspace'] : array(); $site = isset( $res['body']['site'] ) && is_array( $res['body']['site'] ) ? $res['body']['site'] : array(); $acc = array( 'checked' => time(), 'workspace' => isset( $ws['name'] ) ? sanitize_text_field( (string) $ws['name'] ) : '', 'plan' => isset( $ws['plan'] ) ? sanitize_key( (string) $ws['plan'] ) : '', 'live' => ! empty( $ws['live'] ), 'trialDays' => isset( $ws['trialDaysLeft'] ) ? absint( $ws['trialDaysLeft'] ) : null, 'credits' => isset( $ws['credits'] ) && is_numeric( $ws['credits'] ) ? (int) $ws['credits'] : null, 'host' => isset( $site['host'] ) ? sanitize_text_field( (string) $site['host'] ) : '', ); update_option( self::ACCOUNT_OPTION, $acc, false ); if ( isset( $site['trackingKey'] ) && preg_match( '/^[A-Za-z0-9_-]{6,64}$/', (string) $site['trackingKey'] ) ) { update_option( self::TRACKING_OPTION, (string) $site['trackingKey'], false ); } return $acc; } public function tracking_key(): string { $k = (string) get_option( self::TRACKING_OPTION, '' ); return preg_match( '/^[A-Za-z0-9_-]{6,64}$/', $k ) ? $k : ''; } // ------------------------------------------------------------------ API calls /** Companies that visited this site in the last 7 or 30 days, cached for 15 minutes. */ public function visitors( int $days ): array { $days = 30 === $days ? 30 : 7; $cache = get_transient( 'capyscout_visitors_' . $days ); if ( is_array( $cache ) ) { return $cache; } $res = $this->request( 'GET', '/api/wp/v1/visitors?days=' . $days ); $out = array( 'status' => $res['status'], 'error' => $this->error_message( $res ), 'companies' => array(), 'total' => 0, ); if ( 200 === $res['status'] && is_array( $res['body'] ) ) { $out['companies'] = isset( $res['body']['companies'] ) && is_array( $res['body']['companies'] ) ? $res['body']['companies'] : array(); $out['total'] = isset( $res['body']['totalCompanies'] ) ? absint( $res['body']['totalCompanies'] ) : 0; set_transient( 'capyscout_visitors_' . $days, $out, 15 * MINUTE_IN_SECONDS ); } return $out; } /** * Scores one lead. * * @param array $lead email, source, form. * @return array status + body. */ public function score( array $lead ): array { return $this->request( 'POST', '/api/wp/v1/score', $lead ); } /** What CapyScout already knows about a lead (free). Cached 30 minutes per lead. */ public function company( string $email, string $domain = '', string $name = '', bool $fresh = false ): array { $key = 'capyscout_card_' . md5( strtolower( $email . '|' . $domain ) ); if ( ! $fresh ) { $cache = get_transient( $key ); if ( is_array( $cache ) ) { return $cache; } } $res = $this->request( 'POST', '/api/wp/v1/company', array_filter( array( 'email' => $email, 'domain' => $domain, 'name' => $name ), 'strlen' ) ); if ( 200 === $res['status'] && is_array( $res['body'] ) ) { set_transient( $key, $res, 30 * MINUTE_IN_SECONDS ); } return $res; } /** Researches the lead's company now (included in the plan). */ public function research( string $email, string $domain = '', string $name = '' ): array { $res = $this->request( 'POST', '/api/wp/v1/research', array_filter( array( 'email' => $email, 'domain' => $domain, 'name' => $name ), 'strlen' ), 45 ); if ( 200 === $res['status'] && is_array( $res['body'] ) ) { set_transient( 'capyscout_card_' . md5( strtolower( $email . '|' . $domain ) ), $res, 30 * MINUTE_IN_SECONDS ); } return $res; } /** Campaigns the connecting person may add leads to. Cached 5 minutes. */ public function campaigns(): array { $cache = get_transient( 'capyscout_campaigns' ); if ( is_array( $cache ) ) { return $cache; } $res = $this->request( 'GET', '/api/wp/v1/campaigns' ); $out = 200 === $res['status'] && isset( $res['body']['campaigns'] ) && is_array( $res['body']['campaigns'] ) ? $res['body']['campaigns'] : array(); if ( 200 === $res['status'] ) { set_transient( 'capyscout_campaigns', $out, 5 * MINUTE_IN_SECONDS ); } return $out; } /** The workspace's lists. Cached 5 minutes. */ public function lists(): array { $cache = get_transient( 'capyscout_lists' ); if ( is_array( $cache ) ) { return $cache; } $res = $this->request( 'GET', '/api/wp/v1/lists' ); $out = 200 === $res['status'] && isset( $res['body']['lists'] ) && is_array( $res['body']['lists'] ) ? $res['body']['lists'] : array(); if ( 200 === $res['status'] ) { set_transient( 'capyscout_lists', $out, 5 * MINUTE_IN_SECONDS ); } return $out; } /** Adds a lead (or a company, with only a domain) to a campaign. */ public function add_to_campaign( array $lead ): array { return $this->request( 'POST', '/api/wp/v1/actions/add-to-campaign', array_filter( $lead, 'strlen' ) ); } /** Saves a company to a list (or a new list). */ public function add_to_list( array $item ): array { delete_transient( 'capyscout_lists' ); return $this->request( 'POST', '/api/wp/v1/actions/add-to-list', array_filter( $item, 'strlen' ) ); } /** Daily traffic for the last 7, 30 or 90 days. Cached 15 minutes. */ public function traffic( int $days ): array { $days = in_array( $days, array( 30, 90 ), true ) ? $days : 7; $cache = get_transient( 'capyscout_traffic_' . $days ); if ( is_array( $cache ) ) { return $cache; } $res = $this->request( 'GET', '/api/wp/v1/traffic?days=' . $days ); $out = array( 'status' => $res['status'], 'error' => $this->error_message( $res ), 'body' => is_array( $res['body'] ) ? $res['body'] : array(), ); if ( 200 === $res['status'] ) { set_transient( 'capyscout_traffic_' . $days, $out, 15 * MINUTE_IN_SECONDS ); } return $out; } /** * Exchanges a connect code for the site key (server to server). * * @return array status + body. */ public function exchange( string $code, string $verifier, string $redirect_uri ): array { $response = wp_remote_post( CAPYSCOUT_BASE . '/wordpress/token', array( 'timeout' => self::TIMEOUT, 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json' ), 'body' => array( 'code' => $code, 'code_verifier' => $verifier, 'redirect_uri' => $redirect_uri, 'site_url' => self::site_url(), 'site_name' => wp_strip_all_tags( get_bloginfo( 'name' ) ), 'wp_version' => get_bloginfo( 'version' ), 'plugin_version' => CAPYSCOUT_VERSION, ), ) ); return $this->parse( $response ); } /** * One authenticated call. Returns array( 'status' => int, 'body' => array|null ); status 0 * means CapyScout could not be reached. A 401 for a key that no longer works forgets it. */ public function request( string $method, string $path, array $body = array(), int $timeout = self::TIMEOUT ): array { $key = $this->site_key(); if ( '' === $key ) { return array( 'status' => 401, 'body' => null ); } $args = array( 'method' => $method, 'timeout' => $timeout, 'redirection' => 0, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', 'X-WP-Version' => get_bloginfo( 'version' ), 'X-CapyScout-Plugin' => CAPYSCOUT_VERSION, ), ); if ( 'POST' === $method ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( empty( $body ) ? new stdClass() : $body ); } $res = $this->parse( wp_remote_request( CAPYSCOUT_BASE . $path, $args ) ); if ( 401 === $res['status'] ) { $code = $this->error_code( $res ); if ( in_array( $code, array( 'invalid_site_key', 'workspace_closed' ), true ) ) { $this->forget(); update_option( 'capyscout_disconnected_notice', $code, false ); } } return $res; } private function parse( $response ): array { if ( is_wp_error( $response ) ) { return array( 'status' => 0, 'body' => null ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); return array( 'status' => $status, 'body' => is_array( $body ) ? $body : null, ); } public function error_code( array $res ): string { if ( isset( $res['body']['error']['code'] ) ) { return sanitize_key( (string) $res['body']['error']['code'] ); } if ( isset( $res['body']['error'] ) && is_string( $res['body']['error'] ) ) { return sanitize_key( $res['body']['error'] ); } return ''; } public function error_message( array $res ): string { if ( 0 === $res['status'] ) { return __( 'CapyScout could not be reached. Try again in a minute.', 'capyscout' ); } if ( isset( $res['body']['error']['message'] ) ) { return sanitize_text_field( (string) $res['body']['error']['message'] ); } if ( isset( $res['body']['error_description'] ) ) { return sanitize_text_field( (string) $res['body']['error_description'] ); } return 200 === $res['status'] ? '' : __( 'Something went wrong. Try again in a minute.', 'capyscout' ); } // ------------------------------------------------------------------ helpers /** * This site's address: scheme, host and port only. Taken from the admin address, because * CapyScout requires the return page (in wp-admin) to be on exactly this origin. */ public static function site_url(): string { $parts = wp_parse_url( admin_url() ); $out = ( isset( $parts['scheme'] ) ? $parts['scheme'] : 'https' ) . '://' . ( isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '' ); if ( ! empty( $parts['port'] ) ) { $out .= ':' . (int) $parts['port']; } return $out; } /** A CapyScout app link, or the home page when the path is not one of ours. */ public static function app_url( string $path = '/app' ): string { return CAPYSCOUT_BASE . ( 0 === strpos( $path, '/' ) ? $path : '/app' ); } // ------------------------------------------------------------------ key storage private function site_key(): string { $stored = (string) get_option( self::KEY_OPTION, '' ); if ( '' === $stored ) { return ''; } $raw = base64_decode( $stored, true ); if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { return ''; } $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $box = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); try { $key = sodium_crypto_secretbox_open( $box, $nonce, self::box_key() ); } catch ( Exception $e ) { $key = false; } // False when the salts changed or the value was tampered with: treat as not connected. return is_string( $key ) && preg_match( '/^cs_wp_[0-9a-f]{64}$/', $key ) ? $key : ''; } private function store_key( string $key ): bool { try { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $box = sodium_crypto_secretbox( $key, $nonce, self::box_key() ); } catch ( Exception $e ) { return false; } update_option( self::KEY_OPTION, base64_encode( $nonce . $box ), false ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- binary ciphertext storage return true; } private static function box_key(): string { return hash( 'sha256', 'capyscout-site-key|' . wp_salt( 'auth' ), true ); } }