render_settings(); ?>
self::is_configured(), 'status' => self::is_configured() ? (string) $state['status'] : 'not_configured', 'source' => self::constant_key() ? 'configuration' : ( ! empty( $state['api_key'] ) ? 'wordpress' : 'none' ), 'model' => self::get_builder_model(), ); } /** Return the selected Builder AI model after the existing developer filter. */ public static function get_builder_model() { $state = self::state(); $model = isset( $state['model'] ) ? self::normalize_model( $state['model'] ) : self::DEFAULT_MODEL; $model = preg_replace( '/[^A-Za-z0-9._:-]/', '', (string) apply_filters( 'divi_powers_openai_builder_model', $model ) ); return '' !== $model ? $model : self::DEFAULT_MODEL; } /** Return a short customer-facing label for the active Builder model. */ public static function get_builder_model_label() { $model = self::get_builder_model(); $options = self::model_options(); return isset( $options[ $model ] ) ? $options[ $model ]['short_label'] : $model; } /** * Return the key for server-side requests only. * * @return string|WP_Error */ public static function get_api_key() { $constant = self::constant_key(); if ( $constant ) { return $constant; } $state = self::state(); if ( empty( $state['api_key'] ) || ! is_string( $state['api_key'] ) ) { return new WP_Error( 'divi_powers_openai_missing', __( 'Add an OpenAI API key before testing the connection.', '5d-powers-for-divi' ) ); } return self::decrypt( $state['api_key'] ); } /** Save or replace the encrypted administrator-provided key. */ public function handle_save() { $this->require_admin(); if ( self::constant_key() ) { $this->redirect( 'constant' ); } $key = isset( $_POST['openai_api_key'] ) ? trim( (string) wp_unslash( $_POST['openai_api_key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- require_admin() verifies the nonce; preserving the secret byte-for-byte is required before strict format validation and encryption. if ( strlen( $key ) < 20 || strlen( $key ) > 512 || preg_match( '/\s/', $key ) ) { $this->redirect( 'invalid' ); } $encrypted = self::encrypt( $key ); unset( $key ); if ( is_wp_error( $encrypted ) ) { $this->redirect( 'crypto_error' ); } $state = self::state(); $state['api_key'] = $encrypted; $state['status'] = 'not_tested'; $state['last_checked'] = ''; $state['last_error'] = ''; update_option( self::OPTION, $state, false ); $this->redirect( 'saved' ); } /** Save the administrator's preferred model for every Builder AI stage. */ public function handle_model_save() { $this->require_admin(); $model = isset( $_POST['openai_model'] ) ? sanitize_text_field( wp_unslash( $_POST['openai_model'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- require_admin() verifies the nonce immediately above. $options = self::model_options(); if ( ! isset( $options[ $model ] ) ) { $this->redirect( 'invalid_model' ); } $state = self::state(); $state['model'] = $model; update_option( self::OPTION, $state, false ); $this->redirect( 'model_saved' ); } /** Test the stored key from WordPress without returning it to the browser. */ public function handle_test() { $this->require_admin(); $result = $this->test_connection(); $this->redirect( is_wp_error( $result ) ? 'test_failed' : 'connected' ); } /** Remove the database-backed key and its connection status. */ public function handle_remove() { $this->require_admin(); if ( self::constant_key() ) { $this->redirect( 'constant' ); } $state = self::state(); $state['api_key'] = ''; $state['status'] = 'not_configured'; $state['last_checked'] = ''; $state['last_error'] = ''; update_option( self::OPTION, $state, false ); $this->redirect( 'removed' ); } /** Load the local provider settings style on the Builder AI screen. */ public function enqueue_guide_assets( $hook ) { if ( in_array( $hook, array( 'divi-powers_page_divi-powers-connection', 'admin_page_divi-powers-connection' ), true ) && current_user_can( 'manage_options' ) ) { wp_enqueue_style( 'divi-powers-connection', DIVI_POWERS_URL . 'assets/css/connection.css', array(), DIVI_POWERS_VERSION ); } } /** Render the optional on-site OpenAI connection. */ public function render_settings() { if ( ! current_user_can( 'manage_options' ) ) { return; } $state = self::state(); $configured = self::is_configured(); $from_config = (bool) self::constant_key(); $model = self::normalize_model( $state['model'] ); $models = self::model_options(); $status = $configured ? (string) $state['status'] : 'not_configured'; if ( $from_config && 'not_configured' === $status ) { $status = 'not_tested'; } $labels = array( 'connected' => __( 'Connected', '5d-powers-for-divi' ), 'not_tested' => __( 'Saved — test the connection', '5d-powers-for-divi' ), 'error' => __( 'Connection needs attention', '5d-powers-for-divi' ), 'not_configured' => __( 'Not connected', '5d-powers-for-divi' ), ); $message = isset( $_GET['dp_ai_message'] ) ? sanitize_key( wp_unslash( $_GET['dp_ai_message'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>

render_message( $message, $state ); ?>

↗

save_test_status( 'error', $key->get_error_message() ); return $key; } $response = wp_remote_get( self::API_URL, array( 'timeout' => 20, 'redirection' => 0, 'limit_response_size' => self::MAX_RESPONSE_BYTES, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', ), ) ); unset( $key ); if ( is_wp_error( $response ) ) { $error = __( 'WordPress could not reach OpenAI. Check the site connection and try again.', '5d-powers-for-divi' ); $this->save_test_status( 'error', $error ); return new WP_Error( 'divi_powers_openai_network', $error ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code >= 200 && $code < 300 ) { $this->save_test_status( 'connected', '' ); return true; } $messages = array( 401 => __( 'OpenAI did not accept this API key. Replace it and try again.', '5d-powers-for-divi' ), 403 => __( 'This API key does not have permission to use the OpenAI API.', '5d-powers-for-divi' ), 429 => __( 'OpenAI received the request but the account is currently rate-limited or out of API credit.', '5d-powers-for-divi' ), ); $error = isset( $messages[ $code ] ) ? $messages[ $code ] : __( 'OpenAI could not verify the connection. Try again in a moment.', '5d-powers-for-divi' ); $this->save_test_status( 'error', $error ); return new WP_Error( 'divi_powers_openai_http', $error, array( 'status' => $code ) ); } /** Persist only a bounded, non-secret connection result. */ private function save_test_status( $status, $error ) { $state = self::state(); $state['status'] = 'connected' === $status ? 'connected' : 'error'; $state['last_checked'] = gmdate( 'c' ); $state['last_error'] = substr( sanitize_text_field( (string) $error ), 0, 300 ); update_option( self::OPTION, $state, false ); } /** Display one plain-language result on the settings card. */ private function render_message( $message, array $state ) { $success = array( 'saved' => __( 'API key saved. Test the connection when you are ready.', '5d-powers-for-divi' ), 'connected' => __( 'Connected to OpenAI. The key is ready for Visual Builder AI.', '5d-powers-for-divi' ), 'removed' => __( 'The saved OpenAI API key was removed.', '5d-powers-for-divi' ), 'model_saved' => __( 'Builder AI model saved.', '5d-powers-for-divi' ), ); $errors = array( 'invalid' => __( 'Enter a complete OpenAI API key without spaces.', '5d-powers-for-divi' ), 'invalid_model' => __( 'Choose one of the available Builder AI models.', '5d-powers-for-divi' ), 'crypto_error' => __( 'WordPress could not securely save the key on this server.', '5d-powers-for-divi' ), 'constant' => __( 'This key is controlled by the WordPress configuration and cannot be changed here.', '5d-powers-for-divi' ), 'test_failed' => ! empty( $state['last_error'] ) ? $state['last_error'] : __( 'The OpenAI connection test failed.', '5d-powers-for-divi' ), ); if ( isset( $success[ $message ] ) ) { echo '

' . esc_html( $success[ $message ] ) . '

'; } elseif ( isset( $errors[ $message ] ) ) { echo '

' . esc_html( $errors[ $message ] ) . '

'; } } /** Require an administrator and a one-time WordPress form token. */ private function require_admin() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to configure the OpenAI connection.', '5d-powers-for-divi' ) ); } check_admin_referer( self::NONCE_ACTION ); } /** Return to the Divi Powers connection page with a bounded status code. */ private function redirect( $message ) { wp_safe_redirect( add_query_arg( array( 'page' => 'divi-powers-connection', 'dp_ai_message' => sanitize_key( $message ), ), admin_url( 'admin.php' ) ) ); exit; } /** Read the non-autoloaded encrypted-key option with safe defaults. */ private static function state() { $state = get_option( self::OPTION, array() ); $state = is_array( $state ) ? $state : array(); return wp_parse_args( $state, array( 'api_key' => '', 'status' => 'not_configured', 'last_checked' => '', 'last_error' => '', 'model' => self::DEFAULT_MODEL, ) ); } /** * Return Builder-compatible models approved by Divi Powers and available * to the connected OpenAI project. * * The built-in registry is the offline fallback. The canonical registry * lets Divi Powers publish support for future models without requiring a * plugin release. */ private static function model_options() { $models = array( 'gpt-6-astra' => array( 'label' => __( 'GPT-6 Astra — Maximum quality, highest cost', '5d-powers-for-divi' ), 'short_label' => __( 'GPT-6 Astra', '5d-powers-for-divi' ), ), 'gpt-5.6-sol' => array( 'label' => __( 'GPT-5.6 Sol — Best everyday quality (default)', '5d-powers-for-divi' ), 'short_label' => __( 'GPT-5.6 Sol', '5d-powers-for-divi' ), ), 'gpt-5.6-terra' => array( 'label' => __( 'GPT-5.6 Terra — Balanced cost and quality', '5d-powers-for-divi' ), 'short_label' => __( 'GPT-5.6 Terra', '5d-powers-for-divi' ), ), 'gpt-5.6-luna' => array( 'label' => __( 'GPT-5.6 Luna — Lowest cost', '5d-powers-for-divi' ), 'short_label' => __( 'GPT-5.6 Luna', '5d-powers-for-divi' ), ), ); $remote_models = self::remote_model_options(); if ( ! empty( $remote_models ) ) { $models = array_replace( $models, $remote_models ); } $available_ids = self::available_openai_model_ids(); if ( empty( $available_ids ) ) { return $models; } $available = array(); foreach ( $models as $model_id => $details ) { if ( in_array( $model_id, $available_ids, true ) ) { $available[ $model_id ] = $details; } } // Keep an existing selection visible so an administrator can replace it // if OpenAI later removes access to that model. $state = self::state(); $selected = isset( $state['model'] ) ? sanitize_text_field( (string) $state['model'] ) : ''; if ( $selected && isset( $models[ $selected ] ) && ! isset( $available[ $selected ] ) ) { $models[ $selected ]['label'] .= __( ' — Unavailable for this API key', '5d-powers-for-divi' ); $available[ $selected ] = $models[ $selected ]; } return ! empty( $available ) ? $available : $models; } /** Fetch and validate the canonical Divi Powers model registry. */ private static function remote_model_options() { if ( ! Divi_Powers_Plugin::instance()->is_power_enabled( 'model_registry' ) ) { return array(); } $cache_key = 'divi_powers_openai_registry_v1'; $cached = get_site_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $response = wp_safe_remote_get( 'https://divipowers.com/wp-content/plugins/divi-powers/assets/openai-models.json', array( 'timeout' => 5, 'redirection' => 0, 'limit_response_size' => self::MAX_RESPONSE_BYTES, 'headers' => array( 'Accept' => 'application/json' ), 'user-agent' => 'Divi Powers/' . DIVI_POWERS_VERSION, ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { // Avoid repeating a failed lookup for every label rendered in the Builder. set_site_transient( $cache_key, array(), 5 * MINUTE_IN_SECONDS ); return array(); } $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $items = is_array( $data ) && isset( $data['models'] ) && is_array( $data['models'] ) ? $data['models'] : array(); $models = array(); foreach ( array_slice( $items, 0, 20 ) as $item ) { if ( ! is_array( $item ) || empty( $item['builder_compatible'] ) ) { continue; } $model_id = isset( $item['id'] ) ? preg_replace( '/[^A-Za-z0-9._:-]/', '', (string) $item['id'] ) : ''; $label = isset( $item['label'] ) ? sanitize_text_field( (string) $item['label'] ) : ''; $short_label = isset( $item['short_label'] ) ? sanitize_text_field( (string) $item['short_label'] ) : ''; if ( '' === $model_id || '' === $label || '' === $short_label || strlen( $model_id ) > 100 || strlen( $label ) > 160 || strlen( $short_label ) > 80 ) { continue; } $models[ $model_id ] = array( 'label' => $label, 'short_label' => $short_label, ); } set_site_transient( $cache_key, $models, empty( $models ) ? 5 * MINUTE_IN_SECONDS : 12 * HOUR_IN_SECONDS ); return $models; } /** * Return model IDs exposed to this API key, cached per key for 12 hours. * * Failure intentionally returns an empty array so the approved offline * registry remains usable during a temporary OpenAI outage. */ private static function available_openai_model_ids() { if ( ! self::is_configured() ) { return array(); } $key = self::get_api_key(); if ( is_wp_error( $key ) ) { return array(); } $cache_key = 'divi_powers_openai_models_' . substr( hash( 'sha256', $key ), 0, 24 ); $cached = get_site_transient( $cache_key ); if ( is_array( $cached ) ) { unset( $key ); return $cached; } $response = wp_remote_get( self::API_URL, array( 'timeout' => 5, 'redirection' => 0, 'limit_response_size' => self::MAX_RESPONSE_BYTES, 'headers' => array( 'Authorization' => 'Bearer ' . $key, 'Accept' => 'application/json', ), ) ); unset( $key ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { set_site_transient( $cache_key, array(), 5 * MINUTE_IN_SECONDS ); return array(); } $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $rows = is_array( $data ) && isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : array(); $ids = array(); foreach ( array_slice( $rows, 0, 1000 ) as $row ) { $model_id = is_array( $row ) && isset( $row['id'] ) ? preg_replace( '/[^A-Za-z0-9._:-]/', '', (string) $row['id'] ) : ''; if ( '' !== $model_id && strlen( $model_id ) <= 100 ) { $ids[] = $model_id; } } $ids = array_values( array_unique( $ids ) ); set_site_transient( $cache_key, $ids, empty( $ids ) ? 5 * MINUTE_IN_SECONDS : 12 * HOUR_IN_SECONDS ); return $ids; } /** Normalize a stored selection without allowing arbitrary browser input. */ private static function normalize_model( $model ) { $model = sanitize_text_field( (string) $model ); $options = self::model_options(); return isset( $options[ $model ] ) ? $model : self::DEFAULT_MODEL; } /** Support hosts that prefer keeping the key outside the WordPress database. */ private static function constant_key() { if ( ! defined( 'DIVI_POWERS_OPENAI_API_KEY' ) || ! is_string( DIVI_POWERS_OPENAI_API_KEY ) ) { return ''; } return trim( DIVI_POWERS_OPENAI_API_KEY ); } /** Encrypt a key with authenticated encryption derived from WordPress salts. */ private static function encrypt( $plaintext ) { $key = self::encryption_key(); try { if ( function_exists( 'sodium_crypto_secretbox' ) ) { $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $cipher = sodium_crypto_secretbox( $plaintext, $nonce, $key ); return 's1:' . base64_encode( $nonce . $cipher ); } if ( function_exists( 'openssl_encrypt' ) ) { $iv = random_bytes( 12 ); $tag = ''; $cipher = openssl_encrypt( $plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag ); if ( false !== $cipher && 16 === strlen( $tag ) ) { return 'o1:' . base64_encode( $iv . $tag . $cipher ); } } } catch ( Throwable $error ) { return new WP_Error( 'divi_powers_openai_encrypt', __( 'The API key could not be encrypted.', '5d-powers-for-divi' ) ); } return new WP_Error( 'divi_powers_openai_crypto_missing', __( 'This server does not provide a supported encryption library.', '5d-powers-for-divi' ) ); } /** Decrypt and authenticate the database-backed key. */ private static function decrypt( $payload ) { $key = self::encryption_key(); $raw = base64_decode( substr( $payload, 3 ), true ); $type = substr( $payload, 0, 3 ); if ( false === $raw ) { return new WP_Error( 'divi_powers_openai_decrypt', __( 'The saved API key could not be read. Replace it and try again.', '5d-powers-for-divi' ) ); } if ( 's1:' === $type && function_exists( 'sodium_crypto_secretbox_open' ) && strlen( $raw ) > SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); $plain = sodium_crypto_secretbox_open( substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), $nonce, $key ); return false === $plain ? new WP_Error( 'divi_powers_openai_decrypt', __( 'The saved API key could not be verified. Replace it and try again.', '5d-powers-for-divi' ) ) : $plain; } if ( 'o1:' === $type && function_exists( 'openssl_decrypt' ) && strlen( $raw ) > 28 ) { $iv = substr( $raw, 0, 12 ); $tag = substr( $raw, 12, 16 ); $plain = openssl_decrypt( substr( $raw, 28 ), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag ); return false === $plain ? new WP_Error( 'divi_powers_openai_decrypt', __( 'The saved API key could not be verified. Replace it and try again.', '5d-powers-for-divi' ) ) : $plain; } return new WP_Error( 'divi_powers_openai_decrypt', __( 'The saved API key uses an unsupported encryption format.', '5d-powers-for-divi' ) ); } /** Derive a stable 256-bit key without storing another secret. */ private static function encryption_key() { return hash( 'sha256', wp_salt( 'auth' ) . '|' . wp_salt( 'secure_auth' ) . '|divi-powers-openai-v1', true ); } }