(string) $data['site_id'], 'secret' => (string) $data['secret'], 'connected_at' => (int) ( $data['connected_at'] ?? 0 ), 'mode' => (string) ( $data['mode'] ?? 'account' ), 'verified' => (bool) ( $data['verified'] ?? true ), ); } /** * Whether the store is connected. */ public static function is_connected(): bool { return null !== self::get(); } /** * The askshelf.in address that asks the owner to approve connecting this store. * * @param string $return_url The wp-admin page askshelf.in sends the owner back to. */ public static function start( string $return_url ): string { $verifier = self::base64url( random_bytes( 32 ) ); set_transient( self::VERIFIER_TRANSIENT . get_current_user_id(), $verifier, 10 * MINUTE_IN_SECONDS ); return add_query_arg( array( 'connect' => '1', 'home_url' => rawurlencode( home_url( '/' ) ), 'return_url' => rawurlencode( $return_url ), 'challenge' => self::base64url( hash( 'sha256', $verifier, true ) ), ), (string) ASKSHELF_ACCOUNT_URL ); } /** * Start free: connects this store with no account. * * @return array|WP_Error Usage returned by the service, or an error. */ public static function connect_direct() { $verifier = self::base64url( random_bytes( 32 ) ); $challenge = self::base64url( hash( 'sha256', $verifier, true ) ); set_transient( self::PROOF_TRANSIENT . $challenge, $verifier, 10 * MINUTE_IN_SECONDS ); $body = self::post_connect( array( 'mode' => 'direct', 'challenge' => $challenge, ) ); delete_transient( self::PROOF_TRANSIENT . $challenge ); if ( is_wp_error( $body ) ) { return $body; } return self::store( $body, 'direct' ); } /** * The verifier behind a direct-connect challenge, for the service's one call back to * this store. Works once, and only while that connect is in progress. * * @param string $challenge Challenge from the service's request. */ public static function proof( string $challenge ): ?string { if ( ! preg_match( '/^[A-Za-z0-9_-]{43}$/', $challenge ) ) { return null; } $key = self::PROOF_TRANSIENT . $challenge; $verifier = get_transient( $key ); delete_transient( $key ); return is_string( $verifier ) && '' !== $verifier ? $verifier : null; } /** * Redeems the one-time code askshelf.in sent back, connecting this store to the account. * * @param string $code One-time code from askshelf.in. * @return array|WP_Error Usage returned by the service, or an error. */ public static function connect( string $code ) { $key = self::VERIFIER_TRANSIENT . get_current_user_id(); $verifier = get_transient( $key ); delete_transient( $key ); if ( ! is_string( $verifier ) || '' === $verifier ) { return new WP_Error( 'askshelf_connect_expired', __( 'The connection took too long. Click Connect to try again.', 'askshelf' ), array( 'status' => 400 ) ); } $body = self::post_connect( array( 'code' => $code, 'verifier' => $verifier, ) ); if ( is_wp_error( $body ) ) { return $body; } return self::store( $body, 'account' ); } /** * Sends a connect request to the service. * * @param array $fields Mode-specific fields. * @return array|WP_Error The service's reply, or an error. */ private static function post_connect( array $fields ) { $response = wp_remote_post( self::service_url() . '/connect-site', array( 'timeout' => 30, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => (string) wp_json_encode( $fields + array( 'home_url' => home_url( '/' ), 'plugin_version' => ASKSHELF_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'wc_version' => defined( 'WC_VERSION' ) ? WC_VERSION : '', ) ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'askshelf_connect_failed', __( 'Could not reach the Askshelf service. Check that your server can make outgoing HTTPS requests, then try again.', 'askshelf' ), array( 'status' => 502 ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 201 !== $status || ! is_array( $body ) || empty( $body['site_id'] ) || empty( $body['secret'] ) ) { $error = is_array( $body ) ? (string) ( $body['error'] ?? '' ) : ''; if ( 'invalid_code' === $error ) { return new WP_Error( 'askshelf_connect_expired', __( 'That connection link expired or was already used. Click Connect to try again.', 'askshelf' ), array( 'status' => 400 ) ); } if ( 'too_many_attempts' === $error ) { return new WP_Error( 'askshelf_connect_limited', __( 'This server has connected too many stores today. Try again tomorrow.', 'askshelf' ), array( 'status' => 429 ) ); } return new WP_Error( 'askshelf_connect_failed', __( 'The Askshelf service did not accept the connection. Try again in a few minutes.', 'askshelf' ), array( 'status' => 502 ) ); } return $body; } /** * Saves the site id and secret from a connect reply. * * @param array $body Reply from connect-site. * @param string $mode direct or account. * @return array Usage. */ private static function store( array $body, string $mode ): array { update_option( self::OPTION, array( 'site_id' => sanitize_text_field( (string) $body['site_id'] ), 'secret' => sanitize_text_field( (string) $body['secret'] ), 'connected_at' => time(), 'mode' => $mode, 'verified' => ! empty( $body['verified'] ), ), false ); return is_array( $body['usage'] ?? null ) ? $body['usage'] : array(); } /** * Forgets the connection. Chat history stays in the store. */ public static function disconnect(): void { delete_option( self::OPTION ); } /** * Tells the service this store is disconnecting, so its secret stops working. Best * effort: a store that can't reach the service still disconnects locally. A paid plan * is not cancelled, so reinstalling and connecting again restores it. * * @param string $reason uninstalled or owner. */ public static function notify_disconnect( string $reason ): void { $token = self::mint_token(); if ( null === $token ) { return; } wp_remote_post( self::service_url() . '/site/disconnect', array( 'timeout' => 5, 'blocking' => true, 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $token['token'], ), 'body' => (string) wp_json_encode( array( 'reason' => $reason ) ), ) ); } /** * Mints a token the admin page sends to the chat service: * v1.."))> * * @return array{token: string, expires_at: int}|null Null when not connected. */ public static function mint_token(): ?array { $connection = self::get(); if ( null === $connection ) { return null; } $expires = time() + self::TOKEN_TTL; $payload = self::base64url( (string) wp_json_encode( array( 's' => $connection['site_id'], 'e' => $expires, 'n' => wp_generate_uuid4(), ) ) ); $signed = 'v1.' . $payload; $sig = self::base64url( hash_hmac( 'sha256', $signed, $connection['secret'], true ) ); return array( 'token' => $signed . '.' . $sig, 'expires_at' => $expires, ); } /** * URL-safe base64 without padding. * * @param string $data Raw bytes. */ private static function base64url( string $data ): string { return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Token encoding, not obfuscation. } }