array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $auth_token, ), 'body' => wp_json_encode( array() ), 'timeout' => 15, 'sslverify' => self::should_verify_ssl(), ) ); if ( is_wp_error( $response ) ) { return null; } $status_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $response_data = json_decode( $response_body, true ); if ( 200 === $status_code ) { return ! empty( $response_data['valid'] ); } if ( 401 === $status_code || 403 === $status_code ) { return false; } return null; } /** * Exchange a ZipWP app token for a local credit-server auth token. * * @param string $zip_token The ZipWP app token. * @param string $email The user email. * @param string $name The user name. * @since 0.0.1 * @return array The exchange result containing token details, or an empty array on failure. */ public static function exchange_zip_ai_token_for_local_auth_token( $zip_token, $email, $name = '' ) { if ( empty( $zip_token ) || ! is_string( $zip_token ) || empty( $email ) || ! is_string( $email ) ) { return array(); } $request_body = array( 'token' => $zip_token, 'email' => $email, 'site_id' => self::get_site_id(), ); if ( '' !== $name ) { $request_body['name'] = $name; } if ( get_current_user_id() > 0 ) { $request_body['user_id'] = get_current_user_id(); } $response = wp_remote_post( ZIP_AI_CREDIT_SERVER_API . 'token/exchange', array( 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', ), 'body' => wp_json_encode( $request_body ), 'timeout' => 15, 'sslverify' => self::should_verify_ssl(), ) ); if ( is_wp_error( $response ) ) { return array(); } $status_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); $response_data = json_decode( $response_body, true ); if ( ! in_array( $status_code, array( 200, 201 ), true ) || empty( $response_data['success'] ) || empty( $response_data['token'] ) ) { return array(); } return array( 'token' => sanitize_text_field( $response_data['token'] ), 'email' => ! empty( $response_data['email'] ) ? sanitize_email( $response_data['email'] ) : $email, 'name' => ! empty( $response_data['name'] ) ? sanitize_text_field( $response_data['name'] ) : $name, ); } /** * Get the configured credit server identifier for token compatibility checks. * * @since 0.0.1 * @return string The normalized credit server API URL. */ private static function get_credit_server_identifier() { return untrailingslashit( ZIP_AI_CREDIT_SERVER_API ); } /** * Generate a shared secret for HMAC authentication. * * @since 0.0.1 * @return string The generated shared secret. */ public static function generate_shared_secret() { return bin2hex( random_bytes( 32 ) ); } /** * Get or generate the shared secret for HMAC authentication. * * @since 0.0.1 * @return string The shared secret. */ public static function get_shared_secret() { // Get the encrypted shared secret from settings. $encrypted_shared_secret = self::get_setting( 'shared_secret', '' ); if ( empty( $encrypted_shared_secret ) ) { // Generate new shared secret. $shared_secret = self::generate_shared_secret(); self::update_setting( 'shared_secret', $shared_secret ); return $shared_secret; } // Decrypt and return the existing shared secret. return Utils::decrypt( $encrypted_shared_secret ); } /** * Update a specific setting in the Zip AI settings. * * @param string $key The setting key. * @param mixed $value The setting value. * @since 0.0.1 * @return bool True if the setting was updated, false otherwise. */ public static function update_setting( $key, $value ) { $existing_settings = self::get_admin_settings_option( 'zip_ai_settings', array() ); if ( ! is_array( $existing_settings ) ) { $existing_settings = array(); } $existing_settings[ $key ] = Utils::encrypt( $value ); return self::update_admin_settings_option( 'zip_ai_settings', $existing_settings ); } /** * Generate HMAC signature for request. * * @param string $request_body The request body. * @param string $timestamp The timestamp. * @since 0.0.1 * @return string The HMAC signature. */ public static function generate_hmac_signature( $request_body, $timestamp ) { $shared_secret = self::get_shared_secret(); $data = $request_body . $timestamp; return hash_hmac( 'sha256', $data, $shared_secret ); } /** * Get the site ID for HMAC authentication. * * @since 0.0.1 * @return string The site ID. */ public static function get_site_id() { return get_site_url(); } /** * Register the shared secret with Laravel server during plugin activation. * * @since 0.0.1 * @return array The registration response. */ public static function register_shared_secret_with_laravel() { $shared_secret = self::get_shared_secret(); $site_id = self::get_site_id(); // Register endpoint - this should point to your Laravel server. $register_endpoint = ZIP_AI_CREDIT_SERVER_API . 'auth/register-secret'; $registration_data = array( 'site_id' => $site_id, 'secret' => $shared_secret, 'site_name' => get_bloginfo( 'name' ), 'admin_email' => get_option( 'admin_email' ), ); $response = wp_remote_post( $register_endpoint, array( 'headers' => array( 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $registration_data ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { return array( 'error' => $response->get_error_message(), 'code' => 'registration_failed', ); } $response_body = wp_remote_retrieve_body( $response ); $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status_code ) { return array( 'error' => __( 'Failed to register with Laravel server.', 'zip-ai' ), 'code' => 'registration_failed', ); } $response_data = json_decode( $response_body, true ); // Store the registration status. self::update_setting( 'hmac_registered', 'true' ); return $response_data; } /** * Check if HMAC is registered with Laravel. * * @since 0.0.1 * @return bool True if registered, false otherwise. */ public static function is_hmac_registered() { return 'true' === self::get_setting( 'hmac_registered', 'false' ); } /** * Get a response from the ZipWP API server with HMAC authentication. * * @param string $endpoint The endpoint to get the response from. * @param array $body The data to be passed as the request body, if any. * @since 0.0.1 * @return array The ZipWP API Response. */ public static function get_zip_ai_api_response_with_hmac( $endpoint, $body = array() ) { // If the endpoint is not a string, then abandon ship. if ( ! is_string( $endpoint ) ) { return array( 'error' => __( 'The ZipWP Endpoint was not declared', 'zip-ai' ), ); } // Set the API URL. $api_url = ZIP_AI_CREDIT_SERVER_API . $endpoint; // Prepare request body for HMAC signing. $request_body = ''; if ( ! empty( $body ) && is_array( $body ) ) { $request_body = wp_json_encode( $body ); } // Generate timestamp in ISO8601 format. $timestamp = gmdate( 'c' ); // Generate HMAC signature. $signature = self::generate_hmac_signature( $request_body, $timestamp ); // Prepare request arguments with HMAC headers. $request_args = array( 'headers' => array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-Site-Id' => self::get_site_id(), 'X-Timestamp' => $timestamp, 'X-Signature' => $signature, 'X-Plugin-Version' => ZIP_AI_VERSION, ), 'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- 30 seconds is required sometime for the ZipWP API response 'sslverify' => self::should_verify_ssl(), ); // Add request body if provided. if ( ! empty( $request_body ) ) { $request_args['body'] = $request_body; $response = wp_remote_post( $api_url, $request_args ); } else { $response = wp_remote_get( $api_url, $request_args ); } // If the response was an error, or not a 200 status code, then abandon ship. if ( is_wp_error( $response ) || empty( $response['response'] ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return array( 'error' => __( 'The ZipWP API server is not responding.', 'zip-ai' ), ); } // Get the response body. $response_body = wp_remote_retrieve_body( $response ); // If the response body is not a JSON, then abandon ship. if ( empty( $response_body ) || ! json_decode( $response_body ) ) { return array( 'error' => __( 'The ZipWP API server encountered an error.', 'zip-ai' ), ); } // Return the response body. return json_decode( $response_body, true ); } /** * Check if the current user is allowed to use ZipWP MCP. * * @since 0.0.1 * @return bool True if user is allowed, false otherwise. */ public static function is_user_allowed() { return current_user_can( 'manage_zip_ai_assistant' ); } /** * Get all ZipWP MCP settings. * * @since 0.0.1 * @return array The settings array. */ public static function get_settings() { return self::get_setting(); } /** * Get the auth middleware URL with redirect. * Matches UAG implementation exactly. * * @param array $params Optional parameters. * @since 0.0.1 * @return string The auth middleware URL. */ public static function get_auth_middleware_url( $params = array() ) { // Create the Redirect URL. $redirect_url = add_query_arg( array( 'nonce' => wp_create_nonce( 'zip_ai_auth_nonce' ), 'scs-authorize' => 'true', ), admin_url() ); // Create the Authentication URL. $auth_url = add_query_arg( array( 'type' => 'token', 'redirect_url' => rawurlencode( $redirect_url ), ), ZIP_AI_MIDDLEWARE ); // Add the plugin param if passed. if ( ! empty( $params['plugin'] ) && is_string( $params['plugin'] ) ) { $auth_url = add_query_arg( 'plugin', sanitize_text_field( $params['plugin'] ), $auth_url ); } // Add the source param if passed. if ( ! empty( $params['source'] ) && is_string( $params['source'] ) ) { $auth_url = add_query_arg( 'source', sanitize_text_field( $params['source'] ), $auth_url ); } return $auth_url; } /** * Prepare a block array for WordPress serialize_blocks(). * * LLM-generated blocks only have blockName/attrs/innerBlocks. * WordPress serialize_block() requires innerHTML and innerContent * to know how to render the block tree. This adds the missing keys. * * - Blocks with innerBlocks: innerContent = [null, null, ...] (one per child) * - Blocks without innerBlocks: innerContent = [] (self-closing) * - Already-prepared blocks (from parse_blocks): left untouched * * @since 0.0.1 * @param array $blocks Array of block objects. * @return array Blocks ready for serialize_blocks(). */ public static function prepare_blocks_for_serialization( $blocks ) { if ( ! is_array( $blocks ) ) { return array(); } return array_map( function ( $block ) { if ( ! is_array( $block ) ) { return $block; } // Already prepared (e.g. from parse_blocks) — skip if ( isset( $block['innerContent'] ) ) { // Still recurse into innerBlocks in case they need preparation if ( ! empty( $block['innerBlocks'] ) ) { $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] ); } return $block; } // Ensure attrs is an array if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) { $block['attrs'] = array(); } // Recursively prepare innerBlocks first if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] ); // One null per inner block — tells serialize_block() where to place each child $block['innerContent'] = array_fill( 0, count( $block['innerBlocks'] ), null ); } else { $block['innerBlocks'] = array(); $block['innerContent'] = array(); } // innerHTML is empty for Spectra blocks (content is in attrs + innerBlocks) if ( ! isset( $block['innerHTML'] ) ) { $block['innerHTML'] = ''; } return $block; }, $blocks ); } /** * Get installed plugins with their versions. * * Returns a map of plugin_slug => version for all installed plugins (active and inactive). * * @since 0.0.1 * @return array Map of plugin_slug => version. */ public static function get_installed_plugin_versions() { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); $result = array(); // Map of plugin directories to their canonical slugs (for known aliases). $slug_aliases = array( 'ultimate-addons-for-gutenberg' => 'spectra', ); foreach ( $all_plugins as $plugin_file => $plugin_data ) { // Get the plugin directory (first part of plugin_file). $plugin_dir = dirname( $plugin_file ); if ( '.' === $plugin_dir ) { // Single file plugin, use filename without .php. $plugin_dir = str_replace( '.php', '', $plugin_file ); } // Use canonical slug if there's an alias, otherwise use directory name. $slug = $slug_aliases[ $plugin_dir ] ?? $plugin_dir; $result[ $slug ] = $plugin_data['Version'] ?? '0.0.0'; } return $result; } }