'ai-view-llms-txt', 'ai_view_llms_oauth' => '1', ), admin_url( 'admin.php' ) ); } /** * Base URL for the remote connect screen. Filterable; constant overrides empty default. * * @return string */ public function get_connect_base_url() { $base = ''; if ( defined( 'AIVISY_CONNECT_BASE' ) && is_string( AIVISY_CONNECT_BASE ) ) { $base = trim( AIVISY_CONNECT_BASE ); } // One-line setup: AI View app base (no trailing slash) → connect opens …/login.php. if ( '' === $base && defined( 'AIVISY_APP_URL' ) && is_string( AIVISY_APP_URL ) && '' !== trim( AIVISY_APP_URL ) ) { $base = rtrim( trim( AIVISY_APP_URL ), '/' ) . '/login.php'; } /** * Base URL for the AI View "connect WordPress" entrypoint (typically …/login.php). * * @param string $base Full URL or empty if not configured. */ return (string) apply_filters( 'aivisy_connect_base_url', $base ); } /** * Allow wp_safe_redirect() to the AI View app host (external — not the WordPress site). * * Without this, WordPress falls back to wp-admin and the connect flow appears to "refresh only". * * @param array $hosts Allowed redirect hostnames. * @return array */ public function filter_allowed_redirect_hosts( $hosts ) { if ( ! is_array( $hosts ) ) { $hosts = array(); } $candidates = array(); if ( defined( 'AIVISY_APP_URL' ) && is_string( AIVISY_APP_URL ) && '' !== trim( AIVISY_APP_URL ) ) { $candidates[] = trim( AIVISY_APP_URL ); } if ( defined( 'AIVISY_CONNECT_BASE' ) && is_string( AIVISY_CONNECT_BASE ) && '' !== trim( AIVISY_CONNECT_BASE ) ) { $candidates[] = trim( AIVISY_CONNECT_BASE ); } foreach ( $candidates as $url ) { $p = wp_parse_url( $url ); if ( ! empty( $p['host'] ) ) { $hosts[] = $p['host']; } } return array_unique( $hosts ); } /** * Start redirect to AI View. Verifies nonce and capability. * * @return void */ public function handle_connect_request() { // Use our own query arg on the plugin screen — avoid `action=` on admin.php (reserved by core). // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['page'] ) || 'aivisy-settings' !== $_GET['page'] ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['aivisy_start_connect'] ) || '1' !== $_GET['aivisy_start_connect'] ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to connect.', 'ai-view-sync' ) ); } check_admin_referer( 'aivisy_connect' ); $base = $this->get_connect_base_url(); if ( '' === $base ) { wp_safe_redirect( aivisy_make_notice_url( 'connect_config' ) ); exit; } $state = wp_generate_password( 32, false, false ); set_transient( self::TRANSIENT_PREFIX . md5( $state ), array( 't' => time() ), 15 * MINUTE_IN_SECONDS ); $callback = $this->get_callback_url(); $site_url = home_url( '/' ); $args = array( 'state' => $state, 'redirect_uri' => $callback, 'site_url' => $site_url, 'wp_admin_url' => admin_url(), 'client' => 'wordpress', 'client_version' => AIVISY_VERSION, ); /** * Query arguments appended to the connect base URL. * * @param array $args Arguments. * @param string $state OAuth-style state. */ $args = apply_filters( 'aivisy_connect_query_args', $args, $state ); $url = add_query_arg( $args, $base ); /** * Final redirect URL to AI View connect/login. * * @param string $url Full URL. */ $url = apply_filters( 'aivisy_connect_redirect_url', $url ); wp_safe_redirect( $url ); exit; } /** * OAuth callback (admin page). Validates state, stores placeholder connection. * * @return void */ public function handle_callback_request() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- external redirect; state validated via transient. if ( ! isset( $_GET['page'] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page_raw = sanitize_key( self::get_get_string( 'page' ) ); if ( '' === $page_raw ) { return; } $oauth_raw = self::get_get_string( 'ai_view_llms_oauth' ); $oauth_main = ( 'ai-view-llms-txt' === $page_raw && '1' === $oauth_raw ); if ( 'aivisy-callback' !== $page_raw && ! $oauth_main ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to complete connection.', 'ai-view-sync' ) ); } $redirect_fail = aivisy_make_notice_url( 'connect_error' ); try { $state = self::get_get_string( 'state' ); $token = self::get_get_string( 'token' ); if ( '' === $token ) { $token = self::get_get_string( 'code' ); } if ( '' === $state ) { wp_safe_redirect( $redirect_fail ); exit; } $key = self::TRANSIENT_PREFIX . md5( $state ); if ( false === get_transient( $key ) ) { wp_safe_redirect( $redirect_fail ); exit; } delete_transient( $key ); // TODO: Exchange $token with AI View API for a stable connection secret — keep server-side only. $placeholder_token = $token ? $token : 'pending_' . wp_generate_password( 12, false, false ); $av_site = self::get_get_esc_url_raw( 'av_site_url' ); $av_prop = self::get_get_string( 'av_property_id' ); $prop_label = self::get_get_string( 'property' ); $home_norm = Settings::normalize_site_url( home_url( '/' ) ); if ( '' !== $av_site ) { $av_norm = Settings::normalize_site_url( $av_site ); if ( '' === $av_norm || $av_norm !== $home_norm ) { wp_safe_redirect( $redirect_fail ); exit; } } // Do not save av_property_id as bound yet: AI View may send the wrong active property // (e.g. gcco.io vs this site). The wizard + bind API stores the correct token property. $suggested = trim( (string) $av_prop ); if ( '' !== $suggested ) { set_transient( self::TRANSIENT_SUGGESTED_PROPERTY, $suggested, HOUR_IN_SECONDS ); if ( '' !== $prop_label ) { set_transient( self::TRANSIENT_SUGGESTED_LABEL, $prop_label, HOUR_IN_SECONDS ); } else { delete_transient( self::TRANSIENT_SUGGESTED_LABEL ); } } else { delete_transient( self::TRANSIENT_SUGGESTED_PROPERTY ); delete_transient( self::TRANSIENT_SUGGESTED_LABEL ); } $update = array( 'connected' => true, 'connection_token' => $placeholder_token, 'mode' => 'manual', 'bound_site_url' => $home_norm, 'bound_property_id' => '', 'connection_site_id' => '', 'bound_property_label' => '', ); Settings::update( $update ); wp_safe_redirect( aivisy_make_notice_url( 'choose_property' ) ); exit; } catch ( \Throwable $e ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- diagnose connect fatals without exposing details to the browser. error_log( 'aivisy: OAuth callback failed: ' . $e->getMessage() ); } wp_safe_redirect( $redirect_fail ); exit; } } /** * Stub content until real sync returns llms.txt from AI View. * * @return string */ protected static function get_placeholder_synced_body() { $lines = array( '# LLMS.txt', '# This llms.txt was generated with AI View (placeholder — use Connect & Sync for live content).', 'User-agent: *', 'Allow: /', '', '# TODO: Replace with synced content from AI View.', ); return implode( "\n", $lines ); } /** * "Sync now" — calls filter for HTTP client. * * @return bool True on success. */ public function sync_now() { $s = Settings::get_all(); if ( empty( $s['connected'] ) ) { return false; } if ( ! Settings::is_bound_site_current() ) { set_transient( 'aivisy_sync_error', __( 'This WordPress URL does not match the site stored for this connection. Use Connect to AI View again, or fix the site URL, then sync.', 'ai-view-sync' ), 120 ); return false; } if ( Settings::needs_property_selection() ) { set_transient( 'aivisy_sync_error', __( 'Choose which GA4 property powers this WordPress site (step below), then save and sync again.', 'ai-view-sync' ), 120 ); return false; } /** * Perform remote sync: return llms.txt body string, or WP_Error. * $settings includes bound_site_url and bound_property_id for the AI View GA4 property. * * @param string|null $content New body or null to use default behavior. * @param array $settings Current settings. */ $result = apply_filters( 'aivisy_sync_fetch', null, $s ); if ( is_wp_error( $result ) ) { set_transient( 'aivisy_sync_error', $result->get_error_message(), 120 ); return false; } $api_configured = '' !== Sync_Remote::get_api_url() && ! empty( $s['connection_token'] ); if ( is_string( $result ) && trim( $result ) !== '' ) { delete_transient( 'aivisy_sync_error' ); $body = Settings::sanitize_llms_body( $result ); } elseif ( $api_configured ) { set_transient( 'aivisy_sync_error', __( 'AI View did not return LLMS.txt. Open the generator there, create your file, then sync again.', 'ai-view-sync' ), 120 ); return false; } else { $body = self::get_placeholder_synced_body(); } $merged = Llms_Merge::merge( $body, $s ); $body = Settings::sanitize_llms_body( $merged['content'] ); $ga4_c = (int) $merged['ga4_count']; $wp_c = (int) $merged['wp_count']; Llms_Merge::maybe_write_physical_file( $body ); Settings::update( array( 'synced_content' => $body, 'last_synced' => time(), 'last_sync_ga4_count' => $ga4_c, 'last_sync_wp_count' => $wp_c, ) ); return true; } /** * Clear connection and optionally switch to manual mode. * * @param bool $switch_to_manual Whether to set mode to manual. */ public function disconnect( $switch_to_manual = true ) { $data = array( 'connected' => false, 'connection_token' => '', 'connection_site_id' => '', 'bound_site_url' => '', 'bound_property_id' => '', 'bound_property_label' => '', 'last_sync_ga4_count' => 0, 'last_sync_wp_count' => 0, ); if ( $switch_to_manual ) { $data['mode'] = 'manual'; } Settings::update( $data ); delete_transient( self::TRANSIENT_SUGGESTED_PROPERTY ); delete_transient( self::TRANSIENT_SUGGESTED_LABEL ); } }