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 ) . '"';
}
}