*/ public static function defaults() { return array( // La clave del bot. Nunca se enseña en pantalla: se guarda y ya. 'api_key' => '', 'bot_name' => '', 'domains' => array(), 'connected_at' => 0, // Entre «Enviarme el código» y pegar el código, el plugin recuerda a qué // correo se pidió: el canje necesita el correo para encontrar el código // (el backend lo guarda con bcrypt, que no se puede buscar por valor). 'pending_email' => '', 'pending_at' => 0, 'excluded_pages' => array(), ); } /** * @return array */ public static function all() { $saved = get_option( self::OPTION, array() ); if ( ! is_array( $saved ) ) { $saved = array(); } return wp_parse_args( $saved, self::defaults() ); } /** * @param string $key * @return mixed */ public static function get( $key ) { $all = self::all(); return isset( $all[ $key ] ) ? $all[ $key ] : null; } /** * @param array $changes */ public static function update( array $changes ) { update_option( self::OPTION, array_merge( self::all(), $changes ) ); } public static function is_connected() { return '' !== (string) self::get( 'api_key' ); } public static function has_pending_code() { return '' !== (string) self::get( 'pending_email' ); } /** * Olvida la conexión pero conserva lo que el admin configuró a mano (páginas * excluidas y URLs de pruebas): si reconecta, no tiene que volver a hacerlo. * * Los dominios SIGUEN autorizados en el bot. El plugin no puede quitarlos, y está * bien: la pantalla lo dice. */ public static function disconnect() { self::update( array( 'api_key' => '', 'bot_name' => '', 'domains' => array(), 'connected_at' => 0, 'pending_email' => '', 'pending_at' => 0, ) ); } /** * Formato de la clave. Laxo a propósito: exige el prefijo y nada raro dentro, pero * no un largo exacto — hay claves antiguas con otro formato que siguen vivas, y * rechazarlas aquí dejaría fuera a un bot bueno. * * @param string $key */ public static function is_valid_api_key( $key ) { return 1 === preg_match( '/^biz_[A-Za-z0-9._-]{8,}$/', (string) $key ); } /** * URL del bundle: la de producción, salvo que `DOXI_SCRIPT_URL` en wp-config.php diga * otra (desarrollo local, p. ej. el widget compilado en `http://localhost:8080`). * * NO se configura desde la pantalla ni se guarda en la base: un campo que deja elegir * qué JavaScript se carga en la web es lo que la revisión de la tienda mira con lupa, * y el cliente nunca tuvo motivo para tocarlo. * * Una URL sin forma válida (o sin «chat-widget», que el widget necesita para encontrar * su propia etiqueta) cae a la de producción: mejor el chat de siempre que ninguno. */ public static function script_url() { $url = ( defined( 'DOXI_SCRIPT_URL' ) && DOXI_SCRIPT_URL ) ? (string) DOXI_SCRIPT_URL : self::DEFAULT_SCRIPT_URL; $url = (string) apply_filters( 'doxi_script_url', $url ); return self::is_valid_script_url( $url ) ? $url : self::DEFAULT_SCRIPT_URL; } /** * Origen del backend: el de producción, salvo que `DOXI_API_URL` en wp-config.php diga * otro (desarrollo local, p. ej. `http://host.docker.internal:3030`). Mismo criterio * que `script_url()`: fuera de la pantalla, y una URL inválida cae a producción. */ public static function api_url() { $url = ( defined( 'DOXI_API_URL' ) && DOXI_API_URL ) ? (string) DOXI_API_URL : self::DEFAULT_API_URL; $url = untrailingslashit( (string) apply_filters( 'doxi_api_url', $url ) ); return self::is_http_url( $url ) ? $url : self::DEFAULT_API_URL; } /** * ¿Tiene forma de URL http(s) con host? Solo FORMATO. * * ⚠️ No `wp_http_validate_url()`: esa función es la protección contra SSRF de las * peticiones salientes — resuelve el DNS y RECHAZA localhost y las IPs privadas. * Con ella, `http://localhost:3030` (el backend local) y una URL de staging que aún * no resuelve quedaban rechazadas. Salió al correr las pruebas con PHP nativo; en * Playground no hay DNS y pasaba. * * @param string $url */ public static function is_http_url( $url ) { $parts = wp_parse_url( (string) $url ); return is_array( $parts ) && isset( $parts['scheme'], $parts['host'] ) && in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) && '' !== $parts['host']; } /** * El widget solo encuentra su propia etiqueta si el `src` contiene «chat-widget» * (`getScriptParams` en CHATBOT-WIDGET/src/main.tsx). Una URL de pruebas que no lo * contenga dejaría el chat sin montar y sin un solo error. * * @param string $url */ public static function is_valid_script_url( $url ) { return self::is_http_url( $url ) && false !== strpos( (string) $url, 'chat-widget' ); } /** * El host de esta web tal como lo compara el allowlist del backend: sin esquema, y * con el puerto SOLO si la URL lo lleva (así lo normaliza `normalizarDominios`). */ public static function site_host() { $parts = wp_parse_url( home_url() ); $host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; if ( '' !== $host && isset( $parts['port'] ) ) { $host .= ':' . (int) $parts['port']; } return $host; } /** * Los dominios que se mandan al pedir y al canjear el código: el de esta web y su * pareja con o sin «www». Para el allowlist son dominios DISTINTOS y casi todo * WordPress responde por los dos; que el cliente adivine cuál es el suyo sería el * mismo chat mudo con otra cara. * * @return string[] */ public static function site_domains() { $host = self::site_host(); if ( '' === $host ) { return array(); } // En local no hay «www» que valga. if ( self::is_local_host( $host ) ) { return array( $host ); } if ( 0 === strpos( $host, 'www.' ) ) { return array( $host, substr( $host, 4 ) ); } return array( $host, 'www.' . $host ); } /** * @param string $host Con o sin puerto. */ public static function is_local_host( $host ) { $name = preg_replace( '/:\d+$/', '', (string) $host ); if ( 'localhost' === $name || filter_var( $name, FILTER_VALIDATE_IP ) ) { return true; } foreach ( array( '.localhost', '.local', '.test' ) as $suffix ) { if ( substr( $name, -strlen( $suffix ) ) === $suffix ) { return true; } } return false; } }