|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;
?>
-
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' )
);
?>
-
OAuth consent screen.', '4wp-seo-helper' ),
[
'a' => [
'href' => true,
'target' => true,
'rel' => true,
],
]
),
esc_url( 'https://console.cloud.google.com/apis/credentials/consent' )
);
?>
-
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] ?? [];
?>