|null */ private ?array $property_access_cache = null; public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } private function __construct() { add_action( 'plugins_loaded', [ $this, 'maybe_handle_callback' ], 0 ); add_action( 'init', [ $this, 'maybe_handle_callback' ], 5 ); add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); } public function is_connected(): bool { return $this->get_access_token() !== ''; } /** * Remove OAuth tokens and selected property (keeps Client ID / Secret). */ public function disconnect(): void { delete_option( self::OPTION_ACCESS_TOKEN ); delete_option( self::OPTION_REFRESH_TOKEN ); delete_option( self::OPTION_TOKEN_EXPIRES ); delete_option( self::OPTION_SITE ); delete_transient( 'forwp_seo_gsc_last_inspection' ); delete_transient( 'forwp_seo_gsc_last_analytics' ); $this->reset_property_access_cache(); } public function has_property(): bool { return (bool) ( $this->get_property_access_state()['ready'] ?? false ); } public function get_property_access_message(): string { return (string) ( $this->get_property_access_state()['message'] ?? '' ); } /** * @return array{ready:bool,status:string,matched:?string,properties:list,error:string,users_url:string,site_label:string,message:string} */ public function get_property_access_state(): array { if ( null !== $this->property_access_cache ) { return $this->property_access_cache; } if ( ! $this->is_connected() ) { $this->property_access_cache = PropertyAccess::disconnected_state(); return $this->property_access_cache; } $fetch = $this->fetch_properties(); $saved = (string) get_option( self::OPTION_SITE, '' ); $state = PropertyAccess::evaluate( $fetch['properties'], $fetch['error'], $saved ); $matched = is_string( $state['matched'] ?? null ) ? (string) $state['matched'] : ''; if ( $state['ready'] && '' !== $matched ) { if ( $saved !== $matched ) { update_option( self::OPTION_SITE, $matched ); } } elseif ( '' !== $saved ) { delete_option( self::OPTION_SITE ); } $this->property_access_cache = $state; return $this->property_access_cache; } public function reset_property_access_cache(): void { $this->property_access_cache = null; } public function is_menu_visible(): bool { return Module::get_instance()->is_enabled() && $this->is_connected() && $this->has_property(); } public function get_settings_url(): string { return add_query_arg( [ 'page' => Menu::SETTINGS_PAGE_SLUG, 'tab' => 'gsc', ], admin_url( 'admin.php' ) ); } public function get_page_url( string $tab = self::TAB_OVERVIEW, array $extra = [] ): string { $query = array_merge( [ 'page' => Menu::GSC_PAGE_SLUG, 'tab' => $tab, ], $extra ); if ( ! in_array( $tab, [ self::TAB_INSPECTION, self::TAB_SYNC ], true ) ) { $query['range'] = ReportPeriod::get_days(); } return add_query_arg( $query, admin_url( 'admin.php' ) ); } public static function get_site_property(): string { return (string) get_option( self::OPTION_SITE, '' ); } public function get_access_token_for_sync(): string { return $this->get_access_token(); } /** * Handle GSC POST and redirects before any admin HTML is sent. */ public function handle_page_load(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! Module::get_instance()->is_enabled() ) { wp_safe_redirect( add_query_arg( [ 'page' => Menu::SETTINGS_PAGE_SLUG, 'tab' => 'settings', ], admin_url( 'admin.php' ) ) ); exit; } $redirect = $this->handle_data_post(); if ( ! is_string( $redirect ) ) { $redirect = $this->handle_sync_post(); } if ( is_string( $redirect ) && '' !== $redirect ) { wp_safe_redirect( $redirect ); exit; } } public function render_page(): void { ReportPeriod::maybe_save_from_request(); $site = get_option( self::OPTION_SITE, '' ); $is_connected = $this->is_connected(); $tab = $this->get_active_tab(); $report_days = ReportPeriod::resolve_from_request( $tab ); $is_ready = $this->is_menu_visible(); $insights = new Insights(); $repository = new Repository(); $sync = Sync::get_instance(); $sync_running = $is_ready && $sync->is_sync_in_progress( $site ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only performance dimension. $perf_dimension = PageRenderer::resolve_performance_dimension( isset( $_GET['dimension'] ) ? wp_unslash( (string) $_GET['dimension'] ) : 'query' ); $heading_svg = ''; ?>

$perf_dimension ] : [] ); ?>
> build_overview( $site, $report_days ) : [] ); ?>
> build_cards( $site, $report_days ) : [] ); ?>
>
> render_inspection_tab( $site ); ?>
> get_recent_sync_logs( $site, 15 ), $repository->get_last_successful_sync( $site ) ); ?>
%5$s', esc_attr( $id ), $active ? ' is-active' : '', $active ? 'true' : 'false', $active ? 0 : -1, esc_html( $label ) ); } private function get_active_tab(): string { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only tab navigation. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : self::TAB_OVERVIEW; $allowed = [ self::TAB_OVERVIEW, self::TAB_INSIGHTS, self::TAB_PERFORMANCE, self::TAB_INSPECTION, self::TAB_SYNC, ]; if ( ! in_array( $tab, $allowed, true ) ) { $tab = self::TAB_OVERVIEW; } // phpcs:enable WordPress.Security.NonceVerification.Recommended return $tab; } public function render_connect_section( bool $module_enabled = true ): void { $client_id = get_option( self::OPTION_CLIENT_ID, '' ); $client_secret = get_option( self::OPTION_CLIENT_SECRET, '' ); $site = get_option( self::OPTION_SITE, '' ); $is_connected = $this->is_connected(); $redirect_uri = $this->get_redirect_uri(); $redirect_override = (string) get_option( self::OPTION_REDIRECT_URI, '' ); $suggested_uri = $this->get_suggested_oauth_redirect_uri(); $needs_loopback = $this->needs_localhost_redirect_help(); $has_creds = ( '' !== $client_id && '' !== $client_secret ); $redirect_locked = defined( 'FORWP_SEO_GSC_OAUTH_REDIRECT_URI' ); $can_edit_redirect = $needs_loopback && $has_creds && ! $is_connected && ! $redirect_locked; $override_value = '' !== $redirect_override ? $redirect_override : $suggested_uri; ?>

/> 'disabled' ] ); ?>

  1. Search Console API.', '4wp-seo-helper' ), [ 'a' => [ 'href' => true, 'target' => true, 'rel' => true, ], ] ), esc_url( 'https://console.cloud.google.com/apis/library/searchconsole.googleapis.com' ) ); ?>
  2. OAuth consent screen.', '4wp-seo-helper' ), [ 'a' => [ 'href' => true, 'target' => true, 'rel' => true, ], ] ), esc_url( 'https://console.cloud.google.com/apis/credentials/consent' ) ); ?>
  3. Web application) in Credentials, add the Redirect URI below, then paste Client ID and Client Secret here.', '4wp-seo-helper' ), [ 'a' => [ 'href' => true, 'target' => true, 'rel' => true, ], 'strong' => [], ] ), esc_url( 'https://console.cloud.google.com/apis/credentials' ) ); ?>
get_property_access_state(); $properties = $access_state['properties']; $properties_error = $access_state['error']; $manual_mode = PropertyResolver::allows_manual_property_selection(); $matched_site = is_string( $access_state['matched'] ?? null ) ? (string) $access_state['matched'] : null; $site = $matched_site ?? $site; $sync_running = $access_state['ready'] ? Sync::get_instance()->is_sync_in_progress( $site ) : false; $storage = ( new Repository() )->get_storage_counts(); if ( ! $access_state['ready'] && PropertyAccess::STATUS_OK !== $access_state['status'] ) { $this->render_property_access_callout( $access_state ); } ?>

,error:string,users_url:string,site_label:string,message:string} $access */ private function render_property_access_callout( array $access ): void { if ( PropertyAccess::STATUS_PICK_PROPERTY === $access['status'] ) { return; } $users_url = (string) ( $access['users_url'] ?? PropertyAccess::users_url() ); $message = (string) ( $access['message'] ?? '' ); ?>

render_inspection_results(); ?>

render_analytics_results(); ?>
get_settings_url(); if ( isset( $_POST['forwp_seo_gsc_save'] ) ) { update_option( self::OPTION_CLIENT_ID, sanitize_text_field( wp_unslash( (string) ( $_POST['forwp_seo_gsc_client_id'] ?? '' ) ) ) ); update_option( self::OPTION_CLIENT_SECRET, sanitize_text_field( wp_unslash( (string) ( $_POST['forwp_seo_gsc_client_secret'] ?? '' ) ) ) ); return add_query_arg( 'gsc_saved', 'credentials', $redirect_url ); } if ( isset( $_POST['forwp_seo_gsc_save_redirect'] ) ) { $uri = trim( wp_unslash( (string) ( $_POST['forwp_seo_gsc_redirect_uri'] ?? '' ) ) ); if ( '' === $uri ) { delete_option( self::OPTION_REDIRECT_URI ); } else { update_option( self::OPTION_REDIRECT_URI, esc_url_raw( $uri, [ 'http', 'https' ] ) ); } return add_query_arg( 'gsc_saved', 'redirect', $redirect_url ); } if ( isset( $_POST['forwp_seo_gsc_save_property'] ) ) { $local_dev = ! empty( $_POST['forwp_seo_gsc_local_dev_mode'] ); update_option( Module::OPTION_LOCAL_DEV_MODE, $local_dev ? '1' : '0' ); $this->reset_property_access_cache(); if ( $local_dev ) { $selected = $this->sanitize_property_value( wp_unslash( (string) ( $_POST['forwp_seo_gsc_site'] ?? '' ) ) ); $fetch = $this->fetch_properties(); $state = PropertyAccess::evaluate( $fetch['properties'], $fetch['error'], $selected ); if ( ! $state['ready'] ) { delete_option( self::OPTION_SITE ); return add_query_arg( 'gsc_error', rawurlencode( (string) $state['message'] ), $redirect_url ); } update_option( self::OPTION_SITE, $selected ); } elseif ( ! $this->has_property() ) { delete_option( self::OPTION_SITE ); return add_query_arg( 'gsc_error', rawurlencode( $this->get_property_access_message() ), $redirect_url ); } return add_query_arg( 'gsc_saved', 'property', $redirect_url ); } if ( isset( $_POST['forwp_seo_gsc_disconnect'] ) ) { $this->disconnect(); return add_query_arg( 'gsc_disconnected', '1', $redirect_url ); } $clear_redirect = $this->process_clear_data_request( $redirect_url, 'forwp_seo_gsc_settings', 'forwp_seo_gsc_nonce' ); if ( is_string( $clear_redirect ) ) { return $clear_redirect; } return null; } public function process_page_requests(): ?string { if ( ! Module::get_instance()->is_enabled() ) { return add_query_arg( [ 'page' => Menu::SETTINGS_PAGE_SLUG, 'tab' => 'settings', ], admin_url( 'admin.php' ) ); } $redirect = $this->handle_data_post(); if ( ! is_string( $redirect ) ) { $redirect = $this->handle_sync_post(); } return $redirect; } private function handle_data_post(): ?string { if ( ! current_user_can( 'manage_options' ) ) { return null; } if ( empty( $_POST['forwp_seo_gsc_nonce'] ) ) { return null; } $nonce = sanitize_text_field( wp_unslash( (string) $_POST['forwp_seo_gsc_nonce'] ) ); if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'forwp_seo_gsc_settings' ) ) { return null; } if ( isset( $_POST['forwp_seo_gsc_save'] ) || isset( $_POST['forwp_seo_gsc_save_property'] ) ) { return null; } if ( isset( $_POST['forwp_seo_gsc_inspect'] ) ) { $url = esc_url_raw( wp_unslash( (string) ( $_POST['forwp_seo_gsc_inspect_url'] ?? '' ) ) ); $this->set_last_inspection( $url ); return $this->get_page_url( self::TAB_INSPECTION ); } if ( isset( $_POST['forwp_seo_gsc_analytics'] ) ) { $url = esc_url_raw( wp_unslash( (string) ( $_POST['forwp_seo_gsc_analytics_url'] ?? '' ) ) ); $this->set_last_analytics( $url ); return $this->get_page_url( self::TAB_INSPECTION ); } return null; } /** * @return string|null Redirect URL after sync POST. */ private function handle_sync_post(): ?string { if ( ! current_user_can( 'manage_options' ) ) { return null; } if ( empty( $_POST['forwp_seo_gsc_sync_nonce'] ) ) { return null; } $nonce = sanitize_text_field( wp_unslash( (string) $_POST['forwp_seo_gsc_sync_nonce'] ) ); if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'forwp_seo_gsc_sync' ) ) { return null; } if ( isset( $_POST['forwp_seo_gsc_save_brand_terms'] ) ) { $terms = sanitize_textarea_field( wp_unslash( (string) ( $_POST['forwp_seo_gsc_brand_terms'] ?? '' ) ) ); update_option( 'forwp_seo_gsc_brand_terms', $terms ); return add_query_arg( 'brand_saved', '1', $this->get_page_url( self::TAB_SYNC ) ); } if ( isset( $_POST['forwp_seo_gsc_save_cron'] ) ) { $enabled = ! empty( $_POST['forwp_seo_gsc_cron_enabled'] ); update_option( Module::OPTION_CRON_ENABLED, $enabled ? '1' : '0' ); Sync::sync_cron_state( $enabled ); return add_query_arg( 'cron_saved', '1', $this->get_page_url( self::TAB_SYNC ) ); } if ( empty( $_POST['forwp_seo_gsc_run_sync'] ) ) { return null; } $result = Sync::get_instance()->queue_manual_sync( self::get_site_property() ); if ( $result['ok'] ) { return add_query_arg( 'sync_started', '1', $this->get_page_url( self::TAB_SYNC ) ); } return add_query_arg( 'sync_error', rawurlencode( $result['message'] ), $this->get_page_url( self::TAB_SYNC ) ); } public function maybe_handle_callback(): void { if ( ! $this->is_oauth_callback_request() ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- OAuth callback from Google; state transient verified below. $error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( (string) $_GET['error'] ) ) : ''; if ( '' !== $error ) { $args = [ 'gsc_error' => $error, ]; if ( ! empty( $_GET['error_description'] ) ) { $args['gsc_error'] = sanitize_text_field( wp_unslash( (string) $_GET['error_description'] ) ); } $this->redirect_after_oauth( $this->consume_pending_return_url(), $args ); } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['code'] ) ) : ''; $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['state'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( '' === $code || '' === $state || ! preg_match( '/^[a-zA-Z0-9]{32}$/', $state ) ) { return; } $pending = get_transient( $this->pending_state_key( $state ) ); delete_transient( $this->pending_state_key( $state ) ); $return_url = is_array( $pending ) && ! empty( $pending['return_url'] ) ? (string) $pending['return_url'] : null; if ( ! is_array( $pending ) || empty( $pending['return_url'] ) ) { $this->redirect_after_oauth( null, [ 'gsc_error' => 'invalid_state' ] ); } $client_id = get_option( self::OPTION_CLIENT_ID, '' ); $client_secret = get_option( self::OPTION_CLIENT_SECRET, '' ); $client = new Client(); $token = $client->exchange_code( $client_id, $client_secret, $this->get_redirect_uri(), $code ); if ( isset( $token['error'] ) ) { $this->redirect_after_oauth( $return_url, [ 'gsc_error' => (string) $token['error'] ] ); } update_option( self::OPTION_ACCESS_TOKEN, sanitize_text_field( $token['access_token'] ?? '' ) ); if ( ! empty( $token['refresh_token'] ) ) { update_option( self::OPTION_REFRESH_TOKEN, sanitize_text_field( $token['refresh_token'] ) ); } update_option( self::OPTION_TOKEN_EXPIRES, time() + (int) ( $token['expires_in'] ?? 0 ) ); $this->reset_property_access_cache(); $access = $this->get_property_access_state(); if ( ! $access['ready'] ) { delete_option( self::OPTION_SITE ); $this->redirect_after_oauth( $return_url, [ 'gsc_error' => (string) $access['message'], ] ); } $this->redirect_after_oauth( $return_url, [ 'gsc_connected' => '1' ] ); } private function get_connect_url( string $client_id ): string { $state = wp_generate_password( 32, false ); set_transient( $this->pending_state_key( $state ), [ 'user_id' => get_current_user_id(), 'return_url' => $this->get_settings_url(), ], 10 * MINUTE_IN_SECONDS ); $client = new Client(); return $client->get_authorization_url( $client_id, $this->get_redirect_uri(), $state ); } private function get_default_redirect_uri(): string { return admin_url( 'admin.php?page=' . Menu::OAUTH_PAGE_SLUG ); } private function get_redirect_uri(): string { if ( defined( 'FORWP_SEO_GSC_OAUTH_REDIRECT_URI' ) && is_string( FORWP_SEO_GSC_OAUTH_REDIRECT_URI ) && '' !== FORWP_SEO_GSC_OAUTH_REDIRECT_URI ) { return $this->normalize_loopback_redirect_uri( FORWP_SEO_GSC_OAUTH_REDIRECT_URI ); } if ( $this->needs_localhost_redirect_help() ) { $suggested = $this->get_suggested_oauth_redirect_uri(); if ( '' !== $suggested ) { return $suggested; } } $override = (string) get_option( self::OPTION_REDIRECT_URI, '' ); if ( '' !== $override ) { return $this->normalize_loopback_redirect_uri( $override ); } return $this->get_default_redirect_uri(); } private function normalize_loopback_redirect_uri( string $uri ): string { $host = strtolower( (string) wp_parse_url( $uri, PHP_URL_HOST ) ); if ( ! in_array( $host, [ '127.0.0.1', 'localhost', '[::1]' ], true ) ) { return $uri; } $parts = wp_parse_url( $uri ); if ( ! is_array( $parts ) ) { return $uri; } $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; $path = isset( $parts['path'] ) ? $parts['path'] : ''; $query = isset( $parts['query'] ) ? '?' . $parts['query'] : ''; $suffix = $port > 0 && ! in_array( $port, [ 80, 443 ], true ) ? ':' . $port : ''; // Local nginx on this port is HTTP-only. Match the URI already in Google Cloud. return 'http://127.0.0.1' . $suffix . $path . $query; } private function sanitize_redirect_uri_override( string $uri ): string { $uri = trim( $uri ); if ( '' === $uri ) { return ''; } return esc_url_raw( $uri, [ 'http', 'https' ] ); } private function needs_localhost_redirect_help(): bool { $host = wp_parse_url( home_url(), PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return false; } $host = strtolower( $host ); if ( in_array( $host, [ 'localhost', '127.0.0.1', '[::1]' ], true ) ) { return true; } if ( ! str_contains( $host, '.' ) ) { return true; } return (bool) preg_match( '/\.(local|localhost|test|invalid)$/i', $host ); } private function get_suggested_oauth_redirect_uri(): string { if ( ! $this->needs_localhost_redirect_help() ) { return ''; } $parts = wp_parse_url( $this->get_default_redirect_uri() ); if ( ! is_array( $parts ) ) { $parts = [ 'path' => '/wp-admin/admin.php', 'query' => 'page=' . Menu::OAUTH_PAGE_SLUG, ]; } $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; if ( $port <= 0 ) { $port = $this->detect_local_http_port(); } $port_suffix = $port > 0 && ! in_array( $port, [ 80, 443 ], true ) ? ':' . $port : ''; $path = isset( $parts['path'] ) ? $parts['path'] : '/wp-admin/admin.php'; $query = isset( $parts['query'] ) ? '?' . $parts['query'] : ''; return 'http://127.0.0.1' . $port_suffix . $path . $query; } private function detect_local_http_port(): int { if ( ! empty( $_SERVER['SERVER_PORT'] ) ) { $port = (int) $_SERVER['SERVER_PORT']; if ( $port > 0 && ! in_array( $port, [ 80, 443 ], true ) ) { return $port; } } $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_HOST'] ) ) : ''; if ( false !== strpos( $host, ':' ) ) { $port = (int) substr( $host, (int) strrpos( $host, ':' ) + 1 ); if ( $port > 0 ) { return $port; } } $from_app = $this->detect_local_app_http_port(); if ( $from_app > 0 ) { return $from_app; } return (int) apply_filters( 'forwp_seo_gsc_loopback_http_port', 0 ); } private function detect_local_app_http_port(): int { $candidates = []; $home = getenv( 'HOME' ); $home = is_string( $home ) ? $home : ''; if ( '' !== $home ) { $candidates[] = $home . '/Library/Application Support/Local/sites.json'; } $appdata = getenv( 'APPDATA' ); if ( is_string( $appdata ) && '' !== $appdata ) { $candidates[] = $appdata . '/Local/sites.json'; } $abspath = wp_normalize_path( untrailingslashit( ABSPATH ) ); foreach ( $candidates as $file ) { if ( ! is_readable( $file ) ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local app config, not a remote request. $json = file_get_contents( $file ); if ( ! is_string( $json ) || '' === $json ) { continue; } $data = json_decode( $json, true ); if ( ! is_array( $data ) ) { continue; } foreach ( $data as $site ) { if ( ! is_array( $site ) ) { continue; } $path = isset( $site['path'] ) ? (string) $site['path'] : ''; if ( '' !== $home ) { $path = str_replace( '~', $home, $path ); } $path = wp_normalize_path( untrailingslashit( $path ) ); if ( '' === $path || ! str_starts_with( $abspath, $path ) ) { continue; } $port = (int) ( $site['services']['nginx']['ports']['HTTP'][0] ?? 0 ); if ( $port > 0 ) { return $port; } } } return 0; } private function is_oauth_callback_request(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback query flag. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( (string) $_GET['page'] ) ) : ''; if ( Menu::OAUTH_PAGE_SLUG !== $page ) { return false; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ) : ''; return false !== strpos( $uri, 'wp-admin/admin.php' ); } private function pending_state_key( string $state ): string { return 'forwp_seo_gsc_oauth_' . $state; } private function consume_pending( string $state ): ?array { if ( '' === $state || ! preg_match( '/^[a-zA-Z0-9]{32}$/', $state ) ) { return null; } $key = $this->pending_state_key( $state ); $pending = get_transient( $key ); delete_transient( $key ); return is_array( $pending ) ? $pending : null; } private function process_oauth_callback( string $state, string $code, string $error ): void { $pending = $this->consume_pending( $state ); $return_url = is_array( $pending ) && ! empty( $pending['return_url'] ) ? (string) $pending['return_url'] : $this->get_settings_url(); if ( ! is_array( $pending ) ) { $this->redirect_after_oauth( $return_url, [ 'gsc_error' => 'invalid_state' ] ); } if ( '' !== $error ) { $this->redirect_after_oauth( $return_url, [ 'gsc_error' => $error ] ); } if ( '' === $code ) { $this->redirect_after_oauth( $return_url, [ 'gsc_error' => 'missing_code' ] ); } $client_id = get_option( self::OPTION_CLIENT_ID, '' ); $client_secret = get_option( self::OPTION_CLIENT_SECRET, '' ); $client = new Client(); $token = $client->exchange_code( $client_id, $client_secret, $this->get_redirect_uri(), $code ); if ( isset( $token['error'] ) ) { $this->redirect_after_oauth( $return_url, [ 'gsc_error' => (string) $token['error'] ] ); } update_option( self::OPTION_ACCESS_TOKEN, sanitize_text_field( $token['access_token'] ?? '' ) ); if ( ! empty( $token['refresh_token'] ) ) { update_option( self::OPTION_REFRESH_TOKEN, sanitize_text_field( $token['refresh_token'] ) ); } update_option( self::OPTION_TOKEN_EXPIRES, time() + (int) ( $token['expires_in'] ?? 0 ) ); $this->reset_property_access_cache(); $access = $this->get_property_access_state(); if ( ! $access['ready'] ) { delete_option( self::OPTION_SITE ); $this->redirect_after_oauth( $return_url, [ 'gsc_error' => (string) $access['message'], ] ); } $this->redirect_after_oauth( $return_url, [ 'gsc_connected' => '1' ] ); } private function consume_pending_return_url(): ?string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth state from Google callback. $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['state'] ) ) : ''; if ( '' === $state ) { return null; } $pending = $this->consume_pending( $state ); if ( ! is_array( $pending ) || empty( $pending['return_url'] ) ) { return null; } return (string) $pending['return_url']; } /** * @param array $args Query args. */ private function redirect_after_oauth( ?string $return_url, array $args ): void { $base = is_string( $return_url ) && '' !== $return_url ? remove_query_arg( [ 'gsc_connected', 'gsc_error', 'gsc_saved' ], $return_url ) : $this->get_settings_url(); wp_safe_redirect( add_query_arg( $args, $base ) ); exit; } public function register_rest_routes(): void { register_rest_route( 'forwp-seo/v1', '/gsc/callback', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_rest_callback' ], 'permission_callback' => '__return_true', ] ); } public function handle_rest_callback( \WP_REST_Request $request ): \WP_REST_Response { $state = sanitize_text_field( (string) ( $request->get_param( 'state' ) ?? '' ) ); $code = sanitize_text_field( (string) ( $request->get_param( 'code' ) ?? '' ) ); $error = sanitize_text_field( (string) ( $request->get_param( 'error' ) ?? '' ) ); $this->process_oauth_callback( $state, $code, $error ); return new \WP_REST_Response( null, 302 ); } private function get_access_token(): string { $token = get_option( self::OPTION_ACCESS_TOKEN, '' ); $expires = (int) get_option( self::OPTION_TOKEN_EXPIRES, 0 ); if ( $token && time() < $expires - 60 ) { return $token; } $refresh_token = get_option( self::OPTION_REFRESH_TOKEN, '' ); if ( empty( $refresh_token ) ) { return ''; } $client_id = get_option( self::OPTION_CLIENT_ID, '' ); $client_secret = get_option( self::OPTION_CLIENT_SECRET, '' ); if ( empty( $client_id ) || empty( $client_secret ) ) { return ''; } $client = new Client(); $token_data = $client->refresh_token( $client_id, $client_secret, $refresh_token ); if ( isset( $token_data['error'] ) ) { return ''; } update_option( self::OPTION_ACCESS_TOKEN, sanitize_text_field( $token_data['access_token'] ?? '' ) ); update_option( self::OPTION_TOKEN_EXPIRES, time() + (int) ( $token_data['expires_in'] ?? 0 ) ); return (string) ( $token_data['access_token'] ?? '' ); } private function get_properties(): array { return $this->fetch_properties()['properties']; } /** * @return array{properties: list, error: string} */ private function fetch_properties(): array { $token = $this->get_access_token(); if ( '' === $token ) { return [ 'properties' => [], 'error' => __( 'Could not obtain a Google access token. Disconnect and connect again.', '4wp-seo-helper' ), ]; } $client = new Client(); $response = $client->list_sites( $token ); if ( isset( $response['error'] ) ) { return [ 'properties' => [], 'error' => (string) $response['error'], ]; } $properties = []; foreach ( $response['siteEntry'] ?? [] as $entry ) { if ( ! empty( $entry['siteUrl'] ) ) { $properties[] = (string) $entry['siteUrl']; } } return [ 'properties' => $properties, 'error' => '', ]; } private function sanitize_property_value( string $value ): string { $value = sanitize_text_field( $value ); if ( str_starts_with( $value, 'sc-domain:' ) ) { return $value; } return esc_url_raw( $value ); } private function set_last_inspection( string $url ): void { if ( empty( $url ) ) { return; } $site = get_option( self::OPTION_SITE, '' ); $token = $this->get_access_token(); if ( empty( $site ) || empty( $token ) ) { return; } if ( ! $this->url_belongs_to_property( $url, $site ) ) { set_transient( 'forwp_seo_gsc_last_inspection', [ 'error' => __( 'That URL is not part of the selected Search Console property.', '4wp-seo-helper' ), ], 300 ); return; } $client = new Client(); $result = $client->inspect_url( $token, $site, $url ); set_transient( 'forwp_seo_gsc_last_inspection', $result, 300 ); } private function set_last_analytics( string $url ): void { if ( empty( $url ) ) { return; } $site = get_option( self::OPTION_SITE, '' ); $token = $this->get_access_token(); if ( empty( $site ) || empty( $token ) ) { return; } $end = gmdate( 'Y-m-d' ); $start = gmdate( 'Y-m-d', strtotime( '-28 days' ) ); $client = new Client(); $result = $client->search_analytics( $token, $site, $url, $start, $end ); set_transient( 'forwp_seo_gsc_last_analytics', $result, 300 ); } private function url_belongs_to_property( string $url, string $site ): bool { if ( '' === $url || '' === $site ) { return false; } return PropertyResolver::url_belongs_to_property( $url, $site ); } private function sync_property_for_site(): bool { $this->reset_property_access_cache(); $access = $this->get_property_access_state(); return (bool) ( $access['ready'] ?? false ); } /** * @return string|null Redirect URL after clear-data POST. */ private function process_clear_data_request( string $redirect_url, string $nonce_action, string $nonce_field ): ?string { if ( empty( $_POST['forwp_seo_gsc_clear_data'] ) ) { return null; } if ( empty( $_POST[ $nonce_field ] ) ) { return null; } $nonce = sanitize_text_field( wp_unslash( (string) $_POST[ $nonce_field ] ) ); if ( '' === $nonce || ! wp_verify_nonce( $nonce, $nonce_action ) ) { return null; } if ( empty( $_POST['forwp_seo_gsc_clear_confirm'] ) ) { return add_query_arg( 'clear_error', rawurlencode( __( 'Please confirm before clearing synced data.', '4wp-seo-helper' ) ), $redirect_url ); } $property = self::get_site_property(); $sync = Sync::get_instance(); if ( $sync->is_sync_in_progress( $property ) ) { return add_query_arg( 'clear_error', rawurlencode( __( 'Cannot clear data while a sync is running.', '4wp-seo-helper' ) ), $redirect_url ); } Sync::unschedule_manual_syncs(); $sync->release_sync_lock( $property ); ( new Repository() )->clear_all_data(); return add_query_arg( 'data_cleared', '1', $redirect_url ); } private function render_inspection_results(): void { $result = get_transient( 'forwp_seo_gsc_last_inspection' ); if ( empty( $result ) || ! is_array( $result ) ) { return; } if ( isset( $result['error'] ) ) { echo '

' . esc_html( $result['error'] ) . '

'; return; } $inspection = $result['inspectionResult']['indexStatusResult'] ?? []; ?>
' . esc_html( $result['error'] ) . '

'; return; } $row = $result['rows'][0] ?? []; ?>