settings = Settings::instance(); } /** * Register API routes * * @since 1.0.0 */ public function register_routes(): void { // Integrations settings management register_rest_route( $this->namespace, '/' . $this->rest_base . '/settings', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_settings'], 'permission_callback' => [$this, 'check_read_permissions'] ], [ 'methods' => 'POST', 'callback' => [$this, 'update_settings'], 'permission_callback' => [$this, 'check_credential_permissions'], 'args' => $this->get_settings_args() ] ] ); // Test Google API connections register_rest_route( $this->namespace, '/' . $this->rest_base . '/test-connections', [ [ 'methods' => 'POST', 'callback' => [$this, 'test_connections'], 'permission_callback' => [$this, 'check_credential_permissions'] ] ] ); // Verify GA4 tracking register_rest_route( $this->namespace, '/' . $this->rest_base . '/verify-ga4-tracking', [ [ 'methods' => 'POST', 'callback' => [$this, 'verify_ga4_tracking'], 'permission_callback' => [$this, 'check_manage_permissions'], 'args' => [ 'measurement_id' => [ // Optional, and an empty string is meaningful: // verify_tracking() then discovers the ID from the // live homepage. A `required` + `pattern` arg // rejected that at the REST layer before the // handler ran, which is why verification was // unreachable on sites with no stored ID (#250). 'required' => false, 'type' => 'string', 'default' => '', // No regex delimiters — WP's REST validator wraps the // pattern in its own (#...#u), so a leading/trailing // slash would require literal slashes in the value. // The empty alternative keeps discovery reachable // while still rejecting a malformed ID. 'pattern' => '^(G-[A-Z0-9]{10})?$', 'sanitize_callback' => 'sanitize_text_field', 'description' => 'GA4 Measurement ID in format G-XXXXXXXXXX. Omit or leave empty to auto-detect the ID from the site homepage.' ] ] ] ] ); // Detect GA4 conflicts register_rest_route( $this->namespace, '/' . $this->rest_base . '/detect-ga4-conflicts', [ [ 'methods' => 'GET', 'callback' => [$this, 'detect_ga4_conflicts'], 'permission_callback' => [$this, 'check_read_permissions'] ] ] ); // Get Search Console Sites register_rest_route( $this->namespace, '/' . $this->rest_base . '/search-console/sites', [ [ 'methods' => 'GET', 'callback' => [$this, 'get_search_console_sites'], 'permission_callback' => [$this, 'check_manage_permissions'], 'args' => [ 'refresh' => [ 'description' => 'Bypass the cached property list and re-query Google.', 'type' => 'boolean', 'default' => false, ], ], ] ] ); // Disconnect Google Account register_rest_route($this->namespace, '/integrations/google/disconnect', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'disconnect_google_account'], 'permission_callback' => [$this, 'check_credential_permissions'] ]); // Note: there is no save-google-token route. Tokens are swapped // server-to-server in Google_OAuth_Proxy and never pass through the // browser, so there is nothing for the SPA to hand back. } /** * Get integrations settings * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function get_settings(WP_REST_Request $request): WP_REST_Response { try { $settings = $this->get_integrations_settings(); return new WP_REST_Response([ 'success' => true, 'data' => [ 'settings' => $settings ], 'message' => 'Integrations settings retrieved successfully' ], 200); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => 'Failed to retrieve integrations settings: ' . $e->getMessage() ], 500); } } /** * Update integrations settings * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function update_settings(WP_REST_Request $request): WP_REST_Response { try { $settings = $request->get_param('settings'); if (empty($settings) || !is_array($settings)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Invalid settings data provided' ], 400); } // Sanitize and save settings $sanitized_settings = $this->sanitize_settings($settings); $success = $this->save_integrations_settings($sanitized_settings); if ($success) { return new WP_REST_Response([ 'success' => true, 'data' => [ 'settings' => $this->get_integrations_settings() ], 'message' => 'Integrations settings saved successfully' ], 200); } else { return new WP_REST_Response([ 'success' => false, 'message' => 'Failed to save integrations settings' ], 500); } } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => 'Failed to update integrations settings: ' . $e->getMessage() ], 500); } } /** * Test Google API connections * * @since 1.0.0 * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function test_connections(WP_REST_Request $request): WP_REST_Response { try { // Get raw, unmasked API keys directly from Settings class for testing $analytics_key = $this->settings->get('google_analytics_api_key'); $search_console_key = $this->settings->get('google_search_console_api_key'); $pagespeed_key = $this->settings->get('google_pagespeed_api_key'); $results = []; // Test Google Analytics API if (!empty($analytics_key)) { $results['google_analytics'] = $this->test_google_analytics($analytics_key); } else { $results['google_analytics'] = ['status' => 'not_configured', 'message' => 'API key not configured']; } // Test Search Console API if (!empty($search_console_key)) { $results['search_console'] = $this->test_search_console($search_console_key); } else { $results['search_console'] = ['status' => 'not_configured', 'message' => 'API key not configured']; } // Test PageSpeed API (now uses OAuth access token) $access_token = $this->settings->get('google_access_token'); if (!empty($access_token)) { $results['pagespeed'] = $this->test_pagespeed_oauth($access_token); } else { $results['pagespeed'] = ['status' => 'not_configured', 'message' => 'Google account not connected']; } return new WP_REST_Response([ 'success' => true, 'data' => $results, 'message' => 'Connection tests completed' ], 200); } catch (\Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => 'Connection test failed: ' . $e->getMessage() ], 500); } } /** * Get integrations settings from Settings class (with encryption) * * @since 1.0.0 * @return array Settings array */ private function get_integrations_settings(): array { $settings = []; // Get encrypted API keys $settings['google_analytics_api_key'] = $this->settings->get('google_analytics_api_key'); $settings['google_search_console_api_key'] = $this->settings->get('google_search_console_api_key'); $settings['google_pagespeed_api_key'] = $this->settings->get('google_pagespeed_api_key'); // Get GA4 tracking settings (let Settings class handle defaults) $settings['ga4_measurement_id'] = $this->settings->get('ga4_measurement_id'); $settings['ga4_auto_inject'] = $this->settings->get('ga4_auto_inject'); $settings['ga4_anonymize_ip'] = $this->settings->get('ga4_anonymize_ip'); $settings['ga4_exclude_admin'] = $this->settings->get('ga4_exclude_admin'); $settings['ga4_tracking_verified'] = $this->settings->get('ga4_tracking_verified'); $settings['ga4_last_verification'] = $this->settings->get('ga4_last_verification'); // Get other integration settings (let Settings class handle defaults) $settings['api_timeout'] = $this->settings->get('api_timeout'); $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting'); $settings['cache_duration'] = $this->settings->get('cache_duration'); $settings['auto_test_connections'] = $this->settings->get('auto_test_connections'); $settings['retry_failed_requests'] = $this->settings->get('retry_failed_requests'); $settings['google_account_connected'] = $this->settings->get('google_account_connected'); // Mask API keys for security (like OpenAI/Claude keys) $settings['google_analytics_api_key'] = $this->mask_api_key($settings['google_analytics_api_key']); $settings['google_search_console_api_key'] = $this->mask_api_key($settings['google_search_console_api_key']); $settings['google_pagespeed_api_key'] = $this->mask_api_key($settings['google_pagespeed_api_key']); return $settings; } /** * Save integrations settings using Settings class (with encryption) * * @since 1.0.0 * @param array $settings Settings to save * @return bool Success status */ private function save_integrations_settings(array $settings): bool { $success = true; // Save each setting individually using the Settings class // This ensures proper encryption for API keys foreach ($settings as $key => $value) { if (!$this->settings->set($key, $value)) { $success = false; // Setting save failed - error details available through settings manager } } return $success; } /** * Sanitize settings data * * @since 1.0.0 * @param array $settings Raw settings * @return array Sanitized settings */ private function sanitize_settings(array $settings): array { $sanitized = []; // Sanitize API keys. Skip empty values AND the masked sentinel returned // by get_integrations_settings (mask_api_key appends 'XXXX'); resubmitting // the mask must not overwrite the real stored key. foreach (['google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key'] as $key_field) { if (!empty($settings[$key_field]) && !$this->is_masked_api_key($settings[$key_field])) { $sanitized[$key_field] = sanitize_text_field($settings[$key_field]); } } // A key the payload never mentioned is left alone rather than being // reset to a hard-coded default. These fallbacks used to fire on every // save, so a partial payload — or a setting the admin has no control // for, like retry_failed_requests — silently reverted to the default a // site owner had deliberately changed in code (#297). $numeric = ['api_timeout', 'cache_duration']; foreach ($numeric as $key) { if (array_key_exists($key, $settings)) { $sanitized[$key] = absint($settings[$key]); } } $text = ['ga4_measurement_id', 'ga4_last_verification']; foreach ($text as $key) { if (array_key_exists($key, $settings)) { $sanitized[$key] = sanitize_text_field($settings[$key]); } } $booleans = [ 'ga4_auto_inject', 'ga4_anonymize_ip', 'ga4_exclude_admin', 'ga4_tracking_verified', 'enable_rate_limiting', 'auto_test_connections', 'retry_failed_requests', ]; foreach ($booleans as $key) { if (array_key_exists($key, $settings)) { $sanitized[$key] = (bool) $settings[$key]; } } return $sanitized; } /** * Mask API key for security display (XXX pattern) * * @since 1.0.0 * @param string $api_key API key to mask * @return string Masked API key or empty string */ private function mask_api_key(string $api_key): string { if (empty($api_key)) { return ''; } // Show first 6 characters + XXXX suffix (consistent with placeholders) if (strlen($api_key) > 10) { return substr($api_key, 0, 6) . 'XXXX'; } return 'XXXX'; } /** * Whether a submitted value is the masked sentinel produced by mask_api_key * (so we don't persist the mask over a real key). * * @since 1.0.0 * @param string $value Submitted value * @return bool */ private function is_masked_api_key(string $value): bool { return 'XXXX' === $value || str_ends_with($value, 'XXXX'); } /** * Test Google Analytics API connection * * Makes a real API call to Google's PageSpeed Insights API to verify * that the API key is valid and has proper permissions. * * @since 1.0.0 * @param string $api_key API key to test * @return array Test result with status and message */ private function test_google_analytics(string $api_key): array { // Basic format validation if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) { return [ 'status' => 'error', 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.' ]; } // Make a real API call to test the key // Using PageSpeed Insights API as it uses the same API key and has a simple test endpoint $test_url = add_query_arg([ 'url' => 'https://example.com/', 'key' => $api_key, 'category' => 'performance', 'strategy' => 'mobile' ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'); // Get timeout setting from settings or use default $timeout = absint($this->settings->get('api_timeout') ?? 30); $response = wp_remote_get($test_url, [ 'timeout' => $timeout, 'headers' => [ 'Accept' => 'application/json' ], 'sslverify' => true ]); // Handle network/connection errors if (is_wp_error($response)) { return [ 'status' => 'error', 'message' => 'Connection failed: ' . $response->get_error_message() ]; } $response_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); // Handle HTTP errors if ($response_code === 400) { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? 'Bad request'; return [ 'status' => 'error', 'message' => 'API key validation failed: ' . $error_message ]; } if ($response_code === 403) { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? 'Access forbidden'; // Check if it's an API key issue if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) { return [ 'status' => 'error', 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.' ]; } return [ 'status' => 'error', 'message' => 'Access denied: ' . $error_message ]; } if ($response_code === 429) { return [ 'status' => 'configured', 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.' ]; } if ($response_code !== 200) { return [ 'status' => 'error', 'message' => 'API request failed with status code: ' . $response_code ]; } // Validate response body $data = json_decode($response_body, true); if (json_last_error() !== JSON_ERROR_NONE) { return [ 'status' => 'error', 'message' => 'Invalid API response format' ]; } // Check if response has expected structure if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) { return [ 'status' => 'error', 'message' => 'Unexpected API response structure' ]; } // Success - API key is valid and working return [ 'status' => 'configured', 'message' => 'Google API key is valid and working correctly' ]; } /** * Test Google Search Console API connection * * Makes a real API call to Google's PageSpeed Insights API to verify * that the API key is valid and has proper permissions. * * @since 1.0.0 * @param string $api_key API key to test * @return array Test result with status and message */ private function test_search_console(string $api_key): array { // Basic format validation if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) { return [ 'status' => 'error', 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.' ]; } // Make a real API call to test the key // Using PageSpeed Insights API as it uses the same API key format $test_url = add_query_arg([ 'url' => 'https://example.com/', 'key' => $api_key, 'category' => 'seo', 'strategy' => 'desktop' ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'); // Get timeout setting from settings or use default $timeout = absint($this->settings->get('api_timeout') ?? 30); $response = wp_remote_get($test_url, [ 'timeout' => $timeout, 'headers' => [ 'Accept' => 'application/json' ], 'sslverify' => true ]); // Handle network/connection errors if (is_wp_error($response)) { return [ 'status' => 'error', 'message' => 'Connection failed: ' . $response->get_error_message() ]; } $response_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); // Handle HTTP errors if ($response_code === 400) { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? 'Bad request'; return [ 'status' => 'error', 'message' => 'API key validation failed: ' . $error_message ]; } if ($response_code === 403) { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? 'Access forbidden'; // Check if it's an API key issue if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) { return [ 'status' => 'error', 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.' ]; } return [ 'status' => 'error', 'message' => 'Access denied: ' . $error_message ]; } if ($response_code === 429) { return [ 'status' => 'configured', 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.' ]; } if ($response_code !== 200) { return [ 'status' => 'error', 'message' => 'API request failed with status code: ' . $response_code ]; } // Validate response body $data = json_decode($response_body, true); if (json_last_error() !== JSON_ERROR_NONE) { return [ 'status' => 'error', 'message' => 'Invalid API response format' ]; } // Check if response has expected structure if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) { return [ 'status' => 'error', 'message' => 'Unexpected API response structure' ]; } // Success - API key is valid and working return [ 'status' => 'configured', 'message' => 'Google API key is valid and working correctly' ]; } /** * Test Google PageSpeed API connection using OAuth token * * Makes a real API call to Google's PageSpeed Insights API using OAuth * Bearer token to verify that the account is properly connected. * * @since 1.0.0 * @param string $access_token OAuth access token * @return array Test result with status and message */ private function test_pagespeed_oauth(string $access_token): array { if (empty($access_token)) { return [ 'status' => 'error', 'message' => 'Google account not connected. Please connect your Google account to use PageSpeed Insights.' ]; } // Make a real API call to test the OAuth token with PageSpeed API $test_url = add_query_arg([ 'url' => 'https://example.com/', 'category' => 'performance', 'strategy' => 'mobile' ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'); // Get timeout setting from settings or use default $timeout = absint($this->settings->get('api_timeout') ?? 30); $response = wp_remote_get($test_url, [ 'timeout' => $timeout, 'headers' => [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $access_token ], 'sslverify' => true ]); // Handle network/connection errors if (is_wp_error($response)) { return [ 'status' => 'error', 'message' => 'Connection failed: ' . $response->get_error_message() ]; } $response_code = wp_remote_retrieve_response_code($response); $response_body = wp_remote_retrieve_body($response); // Handle HTTP errors if ($response_code === 401) { return [ 'status' => 'error', 'message' => 'Google account token is expired or invalid. Please reconnect your Google account.' ]; } if ($response_code === 403) { $error_data = json_decode($response_body, true); $error_message = $error_data['error']['message'] ?? 'Access forbidden'; return [ 'status' => 'error', 'message' => 'Access denied: ' . $error_message ]; } if ($response_code === 429) { return [ 'status' => 'configured', 'message' => 'API rate limit exceeded. The account is valid but you\'ve reached the quota limit.' ]; } if ($response_code !== 200) { return [ 'status' => 'error', 'message' => 'API request failed with status code: ' . $response_code ]; } // Validate response body $data = json_decode($response_body, true); if (json_last_error() !== JSON_ERROR_NONE) { return [ 'status' => 'error', 'message' => 'Invalid API response format' ]; } // Check if response has expected structure if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) { return [ 'status' => 'error', 'message' => 'Unexpected API response structure' ]; } // Success - OAuth token is valid and working with PageSpeed API return [ 'status' => 'configured', 'message' => 'Google PageSpeed Insights connected successfully via Google OAuth' ]; } /** * Disconnect Google Account * * @since 1.0.0 * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object */ public function disconnect_google_account(WP_REST_Request $request) { try { // Best-effort revoke at Google so the refresh token (which never // auto-expires) can't keep querying on the admin's behalf after // disconnect. Failure here must not block local cleanup. $token_to_revoke = $this->settings->get('google_refresh_token', '') ?: $this->settings->get('google_access_token', ''); if (!empty($token_to_revoke)) { wp_remote_post('https://oauth2.googleapis.com/revoke', [ 'timeout' => 10, 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], 'body' => ['token' => $token_to_revoke], ]); } // Clear all Google-related settings $this->settings->set('google_access_token', ''); $this->settings->set('google_refresh_token', ''); $this->settings->set('google_token_expires_in', ''); $this->settings->set('google_token_created', ''); $this->settings->set('google_account_connected', false); // Also clear site selection. This targeted `google_search_console_site`, // which is not a declared setting — Settings::set() rejects unknown // keys, so the line never cleared anything and the selection // survived every disconnect. The property picker writes // `search_console_property`; clear that and the GA4 property beside // it, so a reconnect under a different Google account doesn't // inherit the previous account's selections. $this->settings->set('search_console_property', ''); $this->settings->set('seo_analytics_google_analytics_property_id', ''); // The cached property list belongs to the account we just dropped — // leaving it would serve those properties to whoever connects next. self::purge_search_console_sites_cache(); // A deliberate disconnect is not a forced re-authorization. delete_option('thinkrank_google_reconnect_required'); return new WP_REST_Response([ 'success' => true, 'message' => 'Google account disconnected successfully' ], 200); } catch (\Exception $e) { return new WP_Error( 'disconnect_failed', 'Failed to disconnect Google account: ' . $e->getMessage(), ['status' => 500] ); } } /** * Get settings arguments for REST API * * @since 1.0.0 * @return array Settings arguments */ private function get_settings_args(): array { return [ 'settings' => [ 'required' => true, 'type' => 'object', 'description' => 'Integrations settings object' ] ]; } /** * Check read permissions * * @since 1.0.0 * @return bool Permission status */ public function check_read_permissions(): bool { return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings'); } /** * Check manage permissions * * @since 1.0.0 * @return bool Permission status */ public function check_manage_permissions(): bool { return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings'); } /** * Check permissions for credential-managing operations. * * Writing provider API keys, disconnecting Google (a server-side token * revoke) and running live connection tests manage the site's third-party * credentials, so they require an administrator — `thinkrank_settings` is * delegatable to non-admin roles through the Role Manager. Mirrors the * pattern used by the brand-visibility and AI-insights key writes. * * @since 1.29.0 * @return bool Permission status */ public function check_credential_permissions(): bool { return current_user_can('manage_options'); } /** * Verify GA4 tracking * Following ThinkRank API response patterns * * @since 1.0.0 * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object */ public function verify_ga4_tracking(WP_REST_Request $request) { try { // Empty is allowed and meaningful: verify_tracking() then reads the // homepage and discovers whichever GA4 ID is actually serving. The // old 400 made verification impossible on OAuth-connected sites // that never typed an ID in — precisely the reported case (#250). $measurement_id = (string) ( $request->get_param('measurement_id') ?? '' ); // Load tracking manager if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php'; } $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager(); $verification_result = $tracking_manager->verify_tracking($measurement_id); return new WP_REST_Response([ 'success' => true, 'data' => $verification_result, 'message' => 'Tracking verification completed' ], 200); } catch (\Exception $e) { return new WP_Error( 'verification_failed', 'Tracking verification failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Detect GA4 conflicts * Following ThinkRank API response patterns * * @since 1.0.0 * @param WP_REST_Request $request Request object * @return WP_REST_Response|WP_Error Response object */ public function detect_ga4_conflicts(WP_REST_Request $request) { try { // Load tracking manager if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php'; } $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager(); $conflicts = $tracking_manager->detect_existing_tracking(); return new WP_REST_Response([ 'success' => true, 'data' => [ 'conflicts' => $conflicts, 'has_conflicts' => !empty($conflicts) ], 'message' => 'Conflict detection completed' ], 200); } catch (\Exception $e) { return new WP_Error( 'conflict_detection_failed', 'Conflict detection failed: ' . $e->getMessage(), ['status' => 500] ); } } /** * Fingerprint the currently connected Google account. * * Prefers the refresh token: it is issued once per authorization grant and * survives every access-token rotation, so the cache stays warm for a whole * connection but changes the moment a different account authorizes. Falls * back to the access token when no refresh token was granted, which merely * shortens the effective cache life to one token lifetime. * * @since 1.28.0 * @return string Non-reversible fingerprint, empty string when disconnected. */ private function get_google_account_fingerprint(): string { $token = $this->settings->get('google_refresh_token', '') ?: $this->settings->get('google_access_token', ''); return empty($token) ? '' : md5((string) $token); } /** * Drop the cached Search Console property list. * * Public and static so the OAuth paths — which run outside this controller * and after the credentials are gone — can invalidate the list on connect, * disconnect and revoke. * * @since 1.28.0 * @return void */ public static function purge_search_console_sites_cache(): void { delete_transient(self::SITES_CACHE_KEY); } /** * Get Search Console Sites * * @since 1.0.0 * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object * * @throws \Exception On failure. */ public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response { try { // Ensure Analytics_Manager is loaded for proactive token refresh if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php'; } // Proactively refresh token if expired — prevents 401 errors on initial load \ThinkRank\SEO\Analytics_Manager::ensure_fresh_token(); // Get access token (now guaranteed fresh if refresh_token is available) $access_token = $this->settings->get('google_access_token'); $api_key = $this->settings->get('google_search_console_api_key'); if (empty($access_token)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Google account not connected' ], 401); } // The verified-sites list changes rarely but costs a live Google // round-trip — serve from a 30-minute transient so the Google // Services screen doesn't hit Google on every render. The cache is // only honoured for the account that wrote it: reconnecting as a // different Google account must never hand back the previous // account's properties, which reads as "my site is missing". $account = $this->get_google_account_fingerprint(); $cached_sites = get_transient(self::SITES_CACHE_KEY); if ( !$request->get_param('refresh') && is_array($cached_sites) && isset($cached_sites['account'], $cached_sites['payload']) && hash_equals($account, (string) $cached_sites['account']) ) { return new WP_REST_Response($cached_sites['payload'], 200); } // Initialize Search Console Client if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) { require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php'; } // Ensure Analytics_Manager is loaded for token refresh if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) { require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php'; } // We need a client that can use the access token $client = new \ThinkRank\Integrations\Google_Search_Console_Client( $api_key ?: '', 30, $access_token ); $max_retries = 1; $retry_count = 0; $sites_data = []; while ($retry_count <= $max_retries) { try { // Fetch sites $sites_data = $client->list_sites(); break; // Success } catch (\Exception $e) { // Check for 401 error if ($e->getCode() === 401 && $retry_count < $max_retries) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...'); } // Initialize Analytics Manager to handle token refresh $analytics_manager = new \ThinkRank\SEO\Analytics_Manager(); $analytics_manager->refresh_access_token(true); // Force refresh // Get new token $new_access_token = $this->settings->get('google_access_token'); // Update client with new token $client = new \ThinkRank\Integrations\Google_Search_Console_Client( $api_key ?: '', 30, $new_access_token ); $retry_count++; continue; } throw $e; } } $sites = []; if (isset($sites_data['siteEntry']) && is_array($sites_data['siteEntry'])) { foreach ($sites_data['siteEntry'] as $site) { $sites[] = [ 'siteUrl' => $site['siteUrl'] ?? '', 'permissionLevel' => $site['permissionLevel'] ?? 'siteOwner' ]; } } $payload = [ 'success' => true, 'data' => [ 'sites' => $sites ], 'message' => 'Search Console sites retrieved successfully' ]; // Cache successes only — errors must stay retryable. Recompute the // fingerprint: the 401 retry above may have rotated the access // token, and the cache must be stamped with the account it came // from, not the one we started the request with. if (!empty($sites)) { set_transient( self::SITES_CACHE_KEY, [ 'account' => $this->get_google_account_fingerprint(), 'payload' => $payload, ], 30 * MINUTE_IN_SECONDS ); } return new WP_REST_Response($payload, 200); } catch (\Exception $e) { $code = $e->getCode(); $status = ($code >= 400 && $code < 600) ? $code : 500; return new WP_REST_Response([ 'success' => false, 'message' => 'Failed to retrieve Search Console sites: ' . $e->getMessage() ], $status); } } }