storage = $storage; $this->releases = $releases; $this->lifecycle = $lifecycle; $this->diagnostics = $diagnostics; } public function register() { add_action( 'init', array( $this, 'register_content_placements' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_global' ) ); add_filter( 'script_loader_tag', array( $this, 'filter_bridge_tag' ), 10, 2 ); } public function register_content_placements() { add_shortcode( 'achla_search', array( $this, 'render_inline' ) ); wp_register_script( 'achlaai-inline-block-editor', ACHLAAI_URL . 'assets/block.js', array( 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-block-editor' ), ACHLAAI_VERSION, true ); register_block_type( 'achlaai/inline-search', array( 'api_version' => 3, 'editor_script' => 'achlaai-inline-block-editor', 'render_callback' => array( $this, 'render_inline' ), 'attributes' => array(), ) ); } public function enqueue_global() { $settings = $this->storage->get(); $mode = $settings['placement']['mode']; if ( ! empty( $settings['runtime_active'] ) && ! empty( $settings['placement']['enabled'] ) && in_array( $mode, array( 'bubble', 'attach' ), true ) ) { $this->enqueue_bridge( $mode ); } } /** * @return string */ public function render_inline() { $settings = $this->storage->get(); if ( 'inline' !== $settings['placement']['mode'] || $this->inline_rendered || ! $this->enqueue_bridge( 'inline' ) ) { return ''; } $this->inline_rendered = true; return ''; } /** * @param string $tag Script tag. * @param string $handle Script handle. * @return string */ public function filter_bridge_tag( $tag, $handle ) { if ( 'achlaai-bridge' !== $handle ) { return $tag; } $settings = $this->storage->get(); if ( ! $this->can_render_frontend( $settings ) ) { return ''; } $mode = in_array( $this->runtime_mode, array( 'bubble', 'inline', 'attach' ), true ) ? $this->runtime_mode : 'bubble'; $selector = 'attach' === $mode ? $settings['placement']['attach_selector'] : ''; $attrs = array( 'src' => ACHLAAI_URL . 'assets/bridge.js', 'id' => 'achlaai-bridge-js', 'data-achla-key' => $settings['connection']['widget_pk'], 'data-achla-mode' => $mode, 'data-achla-attach' => $selector, 'data-achla-decision-url' => rest_url( 'achla/v1/release-decision' ), 'data-achla-diagnostic-url' => 'attach' === $mode ? rest_url( 'achla/v1/runtime-diagnostic' ) : '', 'data-achla-diagnostic-nonce' => 'attach' === $mode ? wp_create_nonce( 'achlaai_runtime_diagnostic' ) : '', 'data-achla-diagnostic-code' => 'attach' === $mode ? $settings['placement']['fallback_observed_code'] : '', 'data-achla-diagnostic-at' => 'attach' === $mode ? (string) (int) $settings['placement']['fallback_observed_at'] : '', ); $parts = array(); foreach ( $attrs as $name => $value ) { $parts[] = esc_attr( $name ) . '="' . esc_attr( $value ) . '"'; } return ''; } /** * @param bool $enabled Output enabled. * @param string $mode Placement mode. * @param string $selector Attach selector. * @return array|\WP_Error */ public function save( $enabled, $mode, $selector ) { if ( ! in_array( $mode, array( 'bubble', 'inline', 'attach' ), true ) ) { return new \WP_Error( 'invalid_placement', __( 'Choose a supported placement.', 'achlaai' ), array( 'status' => 400 ) ); } $settings = $this->storage->get(); if ( 'attach' === $mode ) { $selector = $this->normalize_selector( $selector ); if ( is_wp_error( $selector ) ) { return $selector; } $current = Protocol::canonical_site( home_url( '/' ) ); $test = $settings['placement']; if ( empty( $test['selector_test_ok'] ) || ! Crypto::safe_equal( $test['selector_test_hash'], Crypto::sha256_base64url( $selector ) ) || ! $current || ! Crypto::safe_equal( $test['selector_test_site_key'], $current['site_key'] ) || (int) $test['selector_tested_at'] < time() - self::SELECTOR_TEST_TTL ) { return new \WP_Error( 'selector_test_required', __( 'Test this selector successfully before saving Attach placement.', 'achlaai' ), array( 'status' => 409 ) ); } } else { $selector = ''; } $settings['placement']['enabled'] = (bool) $enabled; $settings['placement']['mode'] = $mode; $settings['placement']['attach_selector'] = $selector; if ( 'attach' !== $mode ) { $settings['placement']['fallback_warning'] = ''; } $this->storage->save( $settings ); return $settings['placement']; } /** * Fetch and inspect the canonical home page using the supported selector subset. * * @param string $selector Selector. * @return array|\WP_Error */ public function test_selector( $selector ) { $selector = $this->normalize_selector( $selector ); if ( is_wp_error( $selector ) ) { return $selector; } $response = wp_safe_remote_get( home_url( '/' ), array( 'timeout' => 10, 'redirection' => 0, 'reject_unsafe_urls' => true, 'limit_response_size' => 524288, ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { $this->store_selector_test( $selector, false, 'selector_page_unreachable' ); return new \WP_Error( 'selector_page_unreachable', __( 'The home page could not be checked safely.', 'achlaai' ), array( 'status' => 502 ) ); } $xpath = $this->selector_to_xpath( $selector ); if ( is_wp_error( $xpath ) ) { return $xpath; } $doc = new \DOMDocument(); $old = libxml_use_internal_errors( true ); $ok = $doc->loadHTML( wp_remote_retrieve_body( $response ), LIBXML_NONET | LIBXML_NOWARNING | LIBXML_NOERROR ); libxml_clear_errors(); libxml_use_internal_errors( $old ); if ( ! $ok ) { $this->store_selector_test( $selector, false, 'selector_page_invalid' ); return new \WP_Error( 'selector_page_invalid', __( 'The home page HTML could not be checked.', 'achlaai' ), array( 'status' => 502 ) ); } $nodes = ( new \DOMXPath( $doc ) )->query( $xpath ); $found = false !== $nodes && $nodes->length > 0; $this->store_selector_test( $selector, $found, $found ? '' : 'attach_target_missing' ); if ( ! $found ) { return new \WP_Error( 'attach_target_missing', __( 'No matching element was found on the home page. Attach would fall back to Bubble.', 'achlaai' ), array( 'status' => 409 ) ); } return array( 'ok' => true, 'matches' => $nodes->length, 'selector' => $selector, ); } /** * @param string $selector Selector. * @return string|\WP_Error */ public function selector_to_xpath( $selector ) { $selector = $this->normalize_selector( $selector ); if ( is_wp_error( $selector ) ) { return $selector; } $tokens = preg_split( '/\s+/', $selector ); $xpath = ''; foreach ( $tokens as $token ) { $match = array(); if ( ! preg_match( '/^([A-Za-z][A-Za-z0-9_-]*)?(?:#([A-Za-z][A-Za-z0-9_-]*))?((?:\.[A-Za-z][A-Za-z0-9_-]*)*)(?:\[([A-Za-z_:][A-Za-z0-9_:.-]*)(?:="([A-Za-z0-9 _.-]{0,100})")?\])?$/', $token, $match ) ) { return new \WP_Error( 'invalid_selector', __( 'Use a simple ID, class, element, or attribute selector.', 'achlaai' ), array( 'status' => 400 ) ); } $tag = '' !== $match[1] ? strtolower( $match[1] ) : '*'; if ( '*' === $tag && empty( $match[2] ) && empty( $match[3] ) && empty( $match[4] ) ) { return new \WP_Error( 'invalid_selector', __( 'The selector is empty or too broad.', 'achlaai' ), array( 'status' => 400 ) ); } $predicates = array(); if ( ! empty( $match[2] ) ) { $predicates[] = '@id=' . $this->xpath_literal( $match[2] ); } if ( ! empty( $match[3] ) ) { foreach ( array_filter( explode( '.', $match[3] ) ) as $class ) { $predicates[] = 'contains(concat(" ", normalize-space(@class), " "), ' . $this->xpath_literal( ' ' . $class . ' ' ) . ')'; } } if ( ! empty( $match[4] ) ) { $predicates[] = isset( $match[5] ) && '' !== $match[5] ? '@' . $match[4] . '=' . $this->xpath_literal( $match[5] ) : '@' . $match[4]; } $xpath .= '//' . $tag . ( $predicates ? '[' . implode( ' and ', $predicates ) . ']' : '' ); } return $xpath; } /** @return string|\WP_Error */ private function normalize_selector( $selector ) { $selector = is_string( $selector ) ? trim( $selector ) : ''; if ( '' === $selector || strlen( $selector ) > 200 || preg_match( '/[\x00-\x1F\x7F,>+~]/', $selector ) ) { return new \WP_Error( 'invalid_selector', __( 'Use a simple selector up to 200 characters.', 'achlaai' ), array( 'status' => 400 ) ); } return preg_replace( '/\s+/', ' ', $selector ); } private function store_selector_test( $selector, $ok, $warning ) { $settings = $this->storage->get(); $site = Protocol::canonical_site( home_url( '/' ) ); $settings['placement']['selector_tested_at'] = time(); $settings['placement']['selector_test_origin'] = $site ? $site['site_origin'] : ''; $settings['placement']['selector_test_site_key'] = $site ? $site['site_key'] : ''; $settings['placement']['selector_test_hash'] = Crypto::sha256_base64url( $selector ); $settings['placement']['selector_test_ok'] = (bool) $ok; $settings['placement']['fallback_warning'] = $warning; $this->storage->save( $settings ); } /** @return bool */ private function enqueue_bridge( $mode ) { $settings = $this->storage->get(); if ( empty( $settings['runtime_active'] ) || empty( $settings['placement']['enabled'] ) || ! $this->storage->has_management_binding( $settings ) ) { return false; } if ( ! $this->can_render_frontend( $settings ) ) { return false; } $this->runtime_mode = $mode; wp_enqueue_script( 'achlaai-bridge', ACHLAAI_URL . 'assets/bridge.js', array(), ACHLAAI_VERSION, true ); return true; } /** @return bool */ private function can_render_frontend( $settings ) { if ( ! $this->storage->frontend_rendering_allowed( $settings ) ) { Lifecycle::schedule_maintenance(); return false; } if ( ! $this->releases->frontend_release() ) { Lifecycle::schedule_maintenance(); return false; } return true; } /** @return string */ private function xpath_literal( $value ) { return '"' . str_replace( '"', '', $value ) . '"'; } }