client = $client; } /** * Build the client for Keepwatch. */ public static function client(): Client { return new Client( array( 'product' => 'keepwatch', 'plugin_file' => KEEPWATCH_PLUGIN_FILE, 'version' => KEEPWATCH_VERSION, 'option_prefix' => 'keepwatch_wpx', 'install_uuid_option' => 'keepwatch_install_uuid', 'api_base' => (string) apply_filters( 'keepwatch_api_base', 'https://wpxlab.com/api/v1' ), ) ); } /** * Current state. * * @return array{connected: bool, site_id: string, name: string, connected_at: int, consent_version: int} */ public static function state(): array { $stored = get_option( self::OPTION ); $stored = is_array( $stored ) ? $stored : array(); return array( 'connected' => ! empty( $stored['connected'] ), 'site_id' => isset( $stored['site_id'] ) ? (string) $stored['site_id'] : '', 'name' => isset( $stored['name'] ) ? (string) $stored['name'] : '', 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0, 'consent_version' => isset( $stored['consent_version'] ) ? (int) $stored['consent_version'] : 0, ); } /** * Whether data may be sent (connected and consent given). */ public static function is_connected(): bool { $state = self::state(); return $state['connected'] && $state['consent_version'] >= self::MIN_CONSENT_VERSION; } /** * Whether the administrator agreed to send the plugin/theme list and site metrics. */ public static function inventory_allowed(): bool { return self::is_connected() && self::state()['consent_version'] >= self::INVENTORY_CONSENT_VERSION; } /** * Record the extra consent for sites connected before it existed (admin action, nonce + capability checked by the caller). */ public static function grant_inventory_consent(): void { $stored = get_option( self::OPTION ); if ( is_array( $stored ) && ! empty( $stored['connected'] ) ) { $stored['consent_version'] = max( (int) ( $stored['consent_version'] ?? 0 ), self::INVENTORY_CONSENT_VERSION ); update_option( self::OPTION, $stored, false ); } } /** * Base64 Ed25519 public key of the WPXLab cloud ('' until known); verifies signed configuration. */ public static function server_key(): string { $stored = get_option( self::OPTION ); return is_array( $stored ) && isset( $stored['server_key'] ) && is_string( $stored['server_key'] ) ? $stored['server_key'] : ''; } /** * Store the server key (only a well-formed 32-byte key is kept). * * @param string $key Base64 key. */ public static function remember_server_key( string $key ): void { $raw = base64_decode( $key, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Ed25519 public key. $stored = get_option( self::OPTION ); if ( false === $raw || 32 !== strlen( $raw ) || ! is_array( $stored ) ) { return; } $stored['server_key'] = $key; update_option( self::OPTION, $stored, false ); } /** * Connect with a code from the WPXLab dashboard. * * @param string $code Connection code. * @param bool $consent Whether the administrator accepted the consent screen. * @return array{ok: bool, error: string, message: string} */ public function connect( string $code, bool $consent ): array { if ( ! $consent ) { return self::failure( 'consent_required', __( 'Please accept what Keepwatch will send before connecting.', 'keepwatch' ) ); } $code = strtoupper( trim( $code ) ); if ( 1 !== preg_match( '/^[0-9A-Z]{4}(-[0-9A-Z]{4}){5}$/', $code ) ) { return self::failure( 'code_format', __( 'The connection code looks wrong. Copy it again from your WPXLab account.', 'keepwatch' ) ); } global $wp_version; // The Hub calls back /wp-json/keepwatch/v1/verify while this request runs; only then do we sign challenges. set_transient( self::PENDING, time(), self::PENDING_SECONDS ); $response = $this->client->api()->request( 'POST', '/keepwatch/enrollments', array( 'code' => $code, 'install_uuid' => $this->client->install_uuid(), 'home_url' => home_url( '/' ), 'plugin_version' => KEEPWATCH_VERSION, 'wp_version' => (string) $wp_version, 'php_version' => PHP_VERSION, ) ); delete_transient( self::PENDING ); if ( 201 !== $response['status'] ) { $error = isset( $response['body']['error'] ) ? (string) $response['body']['error'] : 'unexpected'; return self::failure( $error, self::message_for( $error ) ); } self::remember( $response['body'] ); return array( 'ok' => true, 'error' => '', 'message' => '', ); } /** * Store a successful connection (connection code or CodeCanyon activation) and schedule the first report. * * @param array $body Hub response. */ public static function remember( array $body ): void { update_option( self::OPTION, array( 'connected' => true, 'site_id' => isset( $body['site_id'] ) ? (string) $body['site_id'] : '', 'name' => isset( $body['name'] ) ? (string) $body['name'] : '', 'connected_at' => time(), 'consent_version' => self::CONSENT_VERSION, ), false ); if ( isset( $body['server_key'] ) && is_string( $body['server_key'] ) ) { self::remember_server_key( $body['server_key'] ); } // First report shortly after connecting, then hourly. Reporter::schedule( time() + MINUTE_IN_SECONDS ); } /** * Disconnect. Local state is cleared first, so sending stops even if the Hub is unreachable. */ public function disconnect(): void { $was_connected = self::state()['connected']; delete_option( self::OPTION ); PurchaseCode::forget(); RemoteConfig::forget(); Reporter::unschedule(); if ( $was_connected ) { $this->client->api()->request( 'POST', '/keepwatch/disconnect', array() ); } } /** * Sign the Hub's verification challenge — only while a connect request from this admin is in progress. * * @param string $challenge 32 hex characters from the Hub. */ public function sign_challenge( string $challenge ): ?string { if ( 1 !== preg_match( '/^[a-f0-9]{32}$/', $challenge ) || false === get_transient( self::PENDING ) ) { return null; } $host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) ); return $this->client->sign( "keepwatch-verify\n" . $challenge . "\n" . $host ); } /** * Human readable message for an API error code. * * @param string $error Error code. */ public static function message_for( string $error ): string { switch ( $error ) { case 'enrollment_code_invalid': return __( 'The code is invalid or has expired. Create a new one in your WPXLab account.', 'keepwatch' ); case 'site_limit_reached': return __( 'Your WPXLab plan has no free site slots. Disconnect another site or upgrade.', 'keepwatch' ); case 'site_already_connected': return __( 'This site is connected to another WPXLab account. Disconnect it there first.', 'keepwatch' ); case 'site_unreachable': case 'site_verification_failed': case 'site_url_invalid': return __( 'WPXLab could not reach this site to verify it. The site must be publicly reachable and the REST API must not be blocked.', 'keepwatch' ); case 'network': return __( 'Could not reach wpxlab.com. Check your server\'s outgoing connections and try again.', 'keepwatch' ); default: return __( 'The connection failed. Please try again.', 'keepwatch' ); } } /** * Failure result. * * @param string $error Code. * @param string $message Message. * @return array{ok: bool, error: string, message: string} */ private static function failure( string $error, string $message ): array { return array( 'ok' => false, 'error' => $error, 'message' => $message, ); } }