PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-integrations-endpoint.php +333 -147 1.1.02.7.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Integrations API Endpoints Class
4 5 *
5 6 * Provides REST API endpoints for Google API integrations management
@@ -13,8 +14,9 @@
13 14
14 15 namespace ThinkRank\API;
15 16
16 17 use WP_REST_Controller;
18 +use WP_REST_Server;
17 19 use WP_REST_Request;
18 20 use WP_REST_Response;
19 21 use WP_Error;
20 22 use ThinkRank\Core\Settings;
@@ -50,8 +52,23 @@
50 52 */
51 53 protected $rest_base = 'integrations';
52 54
53 55 /**
56 + * Transient holding the verified Search Console property list.
57 + *
58 + * Deliberately a fixed key rather than one namespaced per account: a purge
59 + * has to be possible from paths that have already cleared the credentials
60 + * (disconnect, revoke), and those can no longer derive an account-specific
61 + * key. The account is instead fingerprinted inside the payload and checked
62 + * on read, so a cache written by one Google account can never be served to
63 + * another even if a purge is missed.
64 + *
65 + * @since 1.28.0
66 + * @var string
67 + */
68 + public const SITES_CACHE_KEY = 'thinkrank_gsc_sites_list';
69 +
70 + /**
54 71 * Settings instance
55 72 *
56 73 * @since 1.0.0
57 74 * @var Settings
@@ -63,9 +80,9 @@
63 80 *
64 81 * @since 1.0.0
65 82 */
66 83 public function __construct() {
67 - $this->settings = new Settings();
84 + $this->settings = Settings::instance();
68 85 }
69 86
70 87 /**
71 88 * Register API routes
@@ -85,9 +102,9 @@
85 102 ],
86 103 [
87 104 'methods' => 'POST',
88 105 'callback' => [$this, 'update_settings'],
89 - 'permission_callback' => [$this, 'check_manage_permissions'],
106 + 'permission_callback' => [$this, 'check_credential_permissions'],
90 107 'args' => $this->get_settings_args()
91 108 ]
92 109 ]
93 110 );
@@ -99,47 +116,43 @@
99 116 [
100 117 [
101 118 'methods' => 'POST',
102 119 'callback' => [$this, 'test_connections'],
103 - 'permission_callback' => [$this, 'check_manage_permissions']
120 + 'permission_callback' => [$this, 'check_credential_permissions']
104 121 ]
105 122 ]
106 123 );
107 124
108 - // Verify GA4 tracking
125 + // Get Search Console Sites
109 126 register_rest_route(
110 127 $this->namespace,
111 - '/' . $this->rest_base . '/verify-ga4-tracking',
128 + '/' . $this->rest_base . '/search-console/sites',
112 129 [
113 130 [
114 - 'methods' => 'POST',
115 - 'callback' => [$this, 'verify_ga4_tracking'],
131 + 'methods' => 'GET',
132 + 'callback' => [$this, 'get_search_console_sites'],
116 133 'permission_callback' => [$this, 'check_manage_permissions'],
117 134 'args' => [
118 - 'measurement_id' => [
119 - 'required' => true,
120 - 'type' => 'string',
121 - 'pattern' => '/^G-[A-Z0-9]{10}$/',
122 - 'sanitize_callback' => 'sanitize_text_field',
123 - 'description' => 'GA4 Measurement ID in format G-XXXXXXXXXX'
124 - ]
125 - ]
135 + 'refresh' => [
136 + 'description' => 'Bypass the cached property list and re-query Google.',
137 + 'type' => 'boolean',
138 + 'default' => false,
139 + ],
140 + ],
126 141 ]
127 142 ]
128 143 );
129 144
130 - // Detect GA4 conflicts
131 - register_rest_route(
132 - $this->namespace,
133 - '/' . $this->rest_base . '/detect-ga4-conflicts',
134 - [
135 - [
136 - 'methods' => 'GET',
137 - 'callback' => [$this, 'detect_ga4_conflicts'],
138 - 'permission_callback' => [$this, 'check_read_permissions']
139 - ]
140 - ]
141 - );
145 + // Disconnect Google Account
146 + register_rest_route($this->namespace, '/integrations/google/disconnect', [
147 + 'methods' => WP_REST_Server::CREATABLE,
148 + 'callback' => [$this, 'disconnect_google_account'],
149 + 'permission_callback' => [$this, 'check_credential_permissions']
150 + ]);
151 +
152 + // Note: there is no save-google-token route. Tokens are swapped
153 + // server-to-server in Google_OAuth_Proxy and never pass through the
154 + // browser, so there is nothing for the SPA to hand back.
142 155 }
143 156
144 157 /**
145 158 * Get integrations settings
@@ -159,9 +172,8 @@
159 172 'settings' => $settings
160 173 ],
161 174 'message' => 'Integrations settings retrieved successfully'
162 175 ], 200);
163 -
164 176 } catch (\Exception $e) {
165 177 return new WP_REST_Response([
166 178 'success' => false,
167 179 'message' => 'Failed to retrieve integrations settings: ' . $e->getMessage()
@@ -205,9 +217,8 @@
205 217 'success' => false,
206 218 'message' => 'Failed to save integrations settings'
207 219 ], 500);
208 220 }
209 -
210 221 } catch (\Exception $e) {
211 222 return new WP_REST_Response([
212 223 'success' => false,
213 224 'message' => 'Failed to update integrations settings: ' . $e->getMessage()
@@ -245,13 +256,14 @@
245 256 } else {
246 257 $results['search_console'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
247 258 }
248 259
249 - // Test PageSpeed API
250 - if (!empty($pagespeed_key)) {
251 - $results['pagespeed'] = $this->test_pagespeed($pagespeed_key);
260 + // Test PageSpeed API (now uses OAuth access token)
261 + $access_token = $this->settings->get('google_access_token');
262 + if (!empty($access_token)) {
263 + $results['pagespeed'] = $this->test_pagespeed_oauth($access_token);
252 264 } else {
253 - $results['pagespeed'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
265 + $results['pagespeed'] = ['status' => 'not_configured', 'message' => 'Google account not connected'];
254 266 }
255 267
256 268 return new WP_REST_Response([
257 269 'success' => true,
@@ -257,9 +269,8 @@
257 269 'success' => true,
258 270 'data' => $results,
259 271 'message' => 'Connection tests completed'
260 272 ], 200);
261 -
262 273 } catch (\Exception $e) {
263 274 return new WP_REST_Response([
264 275 'success' => false,
265 276 'message' => 'Connection test failed: ' . $e->getMessage()
@@ -280,16 +291,8 @@
280 291 $settings['google_analytics_api_key'] = $this->settings->get('google_analytics_api_key');
281 292 $settings['google_search_console_api_key'] = $this->settings->get('google_search_console_api_key');
282 293 $settings['google_pagespeed_api_key'] = $this->settings->get('google_pagespeed_api_key');
283 294
284 - // Get GA4 tracking settings (let Settings class handle defaults)
285 - $settings['ga4_measurement_id'] = $this->settings->get('ga4_measurement_id');
286 - $settings['ga4_auto_inject'] = $this->settings->get('ga4_auto_inject');
287 - $settings['ga4_anonymize_ip'] = $this->settings->get('ga4_anonymize_ip');
288 - $settings['ga4_exclude_admin'] = $this->settings->get('ga4_exclude_admin');
289 - $settings['ga4_tracking_verified'] = $this->settings->get('ga4_tracking_verified');
290 - $settings['ga4_last_verification'] = $this->settings->get('ga4_last_verification');
291 -
292 295 // Get other integration settings (let Settings class handle defaults)
293 296 $settings['api_timeout'] = $this->settings->get('api_timeout');
294 297 $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting');
295 298 $settings['cache_duration'] = $this->settings->get('cache_duration');
@@ -294,8 +297,9 @@
294 297 $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting');
295 298 $settings['cache_duration'] = $this->settings->get('cache_duration');
296 299 $settings['auto_test_connections'] = $this->settings->get('auto_test_connections');
297 300 $settings['retry_failed_requests'] = $this->settings->get('retry_failed_requests');
301 + $settings['google_account_connected'] = $this->settings->get('google_account_connected');
298 302
299 303 // Mask API keys for security (like OpenAI/Claude keys)
300 304 $settings['google_analytics_api_key'] = $this->mask_api_key($settings['google_analytics_api_key']);
301 305 $settings['google_search_console_api_key'] = $this->mask_api_key($settings['google_search_console_api_key']);
@@ -335,36 +339,42 @@
335 339 */
336 340 private function sanitize_settings(array $settings): array {
337 341 $sanitized = [];
338 342
339 - // Sanitize API keys (only if not empty - don't overwrite with empty values)
340 - if (!empty($settings['google_analytics_api_key'])) {
341 - $sanitized['google_analytics_api_key'] = sanitize_text_field($settings['google_analytics_api_key']);
343 + // Sanitize API keys. Skip empty values AND the masked sentinel returned
344 + // by get_integrations_settings (mask_api_key appends 'XXXX'); resubmitting
345 + // the mask must not overwrite the real stored key.
346 + foreach (['google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key'] as $key_field) {
347 + if (!empty($settings[$key_field]) && !$this->is_masked_api_key($settings[$key_field])) {
348 + $sanitized[$key_field] = sanitize_text_field($settings[$key_field]);
349 + }
342 350 }
343 - if (!empty($settings['google_search_console_api_key'])) {
344 - $sanitized['google_search_console_api_key'] = sanitize_text_field($settings['google_search_console_api_key']);
351 +
352 + // A key the payload never mentioned is left alone rather than being
353 + // reset to a hard-coded default. These fallbacks used to fire on every
354 + // save, so a partial payload — or a setting the admin has no control
355 + // for, like retry_failed_requests — silently reverted to the default a
356 + // site owner had deliberately changed in code (#297).
357 + $numeric = ['api_timeout', 'cache_duration'];
358 +
359 + foreach ($numeric as $key) {
360 + if (array_key_exists($key, $settings)) {
361 + $sanitized[$key] = absint($settings[$key]);
362 + }
345 363 }
346 - if (!empty($settings['google_pagespeed_api_key'])) {
347 - $sanitized['google_pagespeed_api_key'] = sanitize_text_field($settings['google_pagespeed_api_key']);
348 - }
349 364
350 - // Sanitize numeric settings
351 - $sanitized['api_timeout'] = absint($settings['api_timeout'] ?? 30);
352 - $sanitized['cache_duration'] = absint($settings['cache_duration'] ?? 3600);
365 + $booleans = [
366 + 'enable_rate_limiting',
367 + 'auto_test_connections',
368 + 'retry_failed_requests',
369 + ];
353 370
354 - // Sanitize GA4 tracking settings
355 - $sanitized['ga4_measurement_id'] = sanitize_text_field($settings['ga4_measurement_id'] ?? '');
356 - $sanitized['ga4_auto_inject'] = isset($settings['ga4_auto_inject']) ? (bool) $settings['ga4_auto_inject'] : false;
357 - $sanitized['ga4_anonymize_ip'] = isset($settings['ga4_anonymize_ip']) ? (bool) $settings['ga4_anonymize_ip'] : false;
358 - $sanitized['ga4_exclude_admin'] = isset($settings['ga4_exclude_admin']) ? (bool) $settings['ga4_exclude_admin'] : false;
359 - $sanitized['ga4_tracking_verified'] = isset($settings['ga4_tracking_verified']) ? (bool) $settings['ga4_tracking_verified'] : false;
360 - $sanitized['ga4_last_verification'] = sanitize_text_field($settings['ga4_last_verification'] ?? '');
371 + foreach ($booleans as $key) {
372 + if (array_key_exists($key, $settings)) {
373 + $sanitized[$key] = (bool) $settings[$key];
374 + }
375 + }
361 376
362 - // Sanitize boolean settings
363 - $sanitized['enable_rate_limiting'] = isset($settings['enable_rate_limiting']) ? (bool) $settings['enable_rate_limiting'] : true;
364 - $sanitized['auto_test_connections'] = isset($settings['auto_test_connections']) ? (bool) $settings['auto_test_connections'] : true;
365 - $sanitized['retry_failed_requests'] = isset($settings['retry_failed_requests']) ? (bool) $settings['retry_failed_requests'] : true;
366 -
367 377 return $sanitized;
368 378 }
369 379
370 380 /**
@@ -387,8 +397,20 @@
387 397 return 'XXXX';
388 398 }
389 399
390 400 /**
401 + * Whether a submitted value is the masked sentinel produced by mask_api_key
402 + * (so we don't persist the mask over a real key).
403 + *
404 + * @since 1.0.0
405 + * @param string $value Submitted value
406 + * @return bool
407 + */
408 + private function is_masked_api_key(string $value): bool {
409 + return 'XXXX' === $value || str_ends_with($value, 'XXXX');
410 + }
411 +
412 + /**
391 413 * Test Google Analytics API connection
392 414 *
393 415 * Makes a real API call to Google's PageSpeed Insights API to verify
394 416 * that the API key is valid and has proper permissions.
@@ -623,30 +645,28 @@
623 645 ];
624 646 }
625 647
626 648 /**
627 - * Test Google PageSpeed API connection
649 + * Test Google PageSpeed API connection using OAuth token
628 650 *
629 - * Makes a real API call to Google's PageSpeed Insights API to verify
630 - * that the API key is valid and has proper permissions.
651 + * Makes a real API call to Google's PageSpeed Insights API using OAuth
652 + * Bearer token to verify that the account is properly connected.
631 653 *
632 654 * @since 1.0.0
633 - * @param string $api_key API key to test
655 + * @param string $access_token OAuth access token
634 656 * @return array Test result with status and message
635 657 */
636 - private function test_pagespeed(string $api_key): array {
637 - // Basic format validation
638 - if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
658 + private function test_pagespeed_oauth(string $access_token): array {
659 + if (empty($access_token)) {
639 660 return [
640 661 'status' => 'error',
641 - 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
662 + 'message' => 'Google account not connected. Please connect your Google account to use PageSpeed Insights.'
642 663 ];
643 664 }
644 665
645 - // Make a real API call to test the key
666 + // Make a real API call to test the OAuth token with PageSpeed API
646 667 $test_url = add_query_arg([
647 668 'url' => 'https://example.com/',
648 - 'key' => $api_key,
649 669 'category' => 'performance',
650 670 'strategy' => 'mobile'
651 671 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
652 672
@@ -655,9 +675,10 @@
655 675
656 676 $response = wp_remote_get($test_url, [
657 677 'timeout' => $timeout,
658 678 'headers' => [
659 - 'Accept' => 'application/json'
679 + 'Accept' => 'application/json',
680 + 'Authorization' => 'Bearer ' . $access_token
660 681 ],
661 682 'sslverify' => true
662 683 ]);
663 684
@@ -672,15 +693,12 @@
672 693 $response_code = wp_remote_retrieve_response_code($response);
673 694 $response_body = wp_remote_retrieve_body($response);
674 695
675 696 // Handle HTTP errors
676 - if ($response_code === 400) {
677 - $error_data = json_decode($response_body, true);
678 - $error_message = $error_data['error']['message'] ?? 'Bad request';
679 -
697 + if ($response_code === 401) {
680 698 return [
681 699 'status' => 'error',
682 - 'message' => 'API key validation failed: ' . $error_message
700 + 'message' => 'Google account token is expired or invalid. Please reconnect your Google account.'
683 701 ];
684 702 }
685 703
686 704 if ($response_code === 403) {
@@ -685,17 +703,8 @@
685 703
686 704 if ($response_code === 403) {
687 705 $error_data = json_decode($response_body, true);
688 706 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
689 -
690 - // Check if it's an API key issue
691 - if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
692 - return [
693 - 'status' => 'error',
694 - 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
695 - ];
696 - }
697 -
698 707 return [
699 708 'status' => 'error',
700 709 'message' => 'Access denied: ' . $error_message
701 710 ];
@@ -703,9 +712,9 @@
703 712
704 713 if ($response_code === 429) {
705 714 return [
706 715 'status' => 'configured',
707 - 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
716 + 'message' => 'API rate limit exceeded. The account is valid but you\'ve reached the quota limit.'
708 717 ];
709 718 }
710 719
711 720 if ($response_code !== 200) {
@@ -732,16 +741,75 @@
732 741 'message' => 'Unexpected API response structure'
733 742 ];
734 743 }
735 744
736 - // Success - API key is valid and working
745 + // Success - OAuth token is valid and working with PageSpeed API
737 746 return [
738 747 'status' => 'configured',
739 - 'message' => 'Google API key is valid and working correctly'
748 + 'message' => 'Google PageSpeed Insights connected successfully via Google OAuth'
740 749 ];
741 750 }
742 751
743 752 /**
753 + * Disconnect Google Account
754 + *
755 + * @since 1.0.0
756 + * @param WP_REST_Request $request Request object
757 + * @return WP_REST_Response|WP_Error Response object
758 + */
759 + public function disconnect_google_account(WP_REST_Request $request) {
760 + try {
761 + // Best-effort revoke at Google so the refresh token (which never
762 + // auto-expires) can't keep querying on the admin's behalf after
763 + // disconnect. Failure here must not block local cleanup.
764 + $token_to_revoke = $this->settings->get('google_refresh_token', '')
765 + ?: $this->settings->get('google_access_token', '');
766 + if (!empty($token_to_revoke)) {
767 + wp_remote_post('https://oauth2.googleapis.com/revoke', [
768 + 'timeout' => 10,
769 + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
770 + 'body' => ['token' => $token_to_revoke],
771 + ]);
772 + }
773 +
774 + // Clear all Google-related settings
775 + $this->settings->set('google_access_token', '');
776 + $this->settings->set('google_refresh_token', '');
777 + $this->settings->set('google_token_expires_in', '');
778 + $this->settings->set('google_token_created', '');
779 + $this->settings->set('google_account_connected', false);
780 + // Also clear site selection. This targeted `google_search_console_site`,
781 + // which is not a declared setting — Settings::set() rejects unknown
782 + // keys, so the line never cleared anything and the selection
783 + // survived every disconnect. The property picker writes
784 + // `search_console_property`; clear that and the GA4 property beside
785 + // it, so a reconnect under a different Google account doesn't
786 + // inherit the previous account's selections.
787 + $this->settings->set('search_console_property', '');
788 + $this->settings->set('seo_analytics_google_analytics_property_id', '');
789 +
790 + // The cached property list belongs to the account we just dropped —
791 + // leaving it would serve those properties to whoever connects next.
792 + self::purge_search_console_sites_cache();
793 +
794 + // A deliberate disconnect is not a forced re-authorization.
795 + delete_option('thinkrank_google_reconnect_required');
796 +
797 + return new WP_REST_Response([
798 + 'success' => true,
799 + 'message' => 'Google account disconnected successfully'
800 + ], 200);
801 + } catch (\Exception $e) {
802 + return new WP_Error(
803 + 'disconnect_failed',
804 + 'Failed to disconnect Google account: ' . $e->getMessage(),
805 + ['status' => 500]
806 + );
807 + }
808 + }
809 +
810 +
811 + /**
744 812 * Get settings arguments for REST API
745 813 *
746 814 * @since 1.0.0
747 815 * @return array Settings arguments
@@ -762,9 +830,9 @@
762 830 * @since 1.0.0
763 831 * @return bool Permission status
764 832 */
765 833 public function check_read_permissions(): bool {
766 - return current_user_can('manage_options');
834 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
767 835 }
768 836
769 837 /**
770 838 * Check manage permissions
@@ -772,86 +840,204 @@
772 840 * @since 1.0.0
773 841 * @return bool Permission status
774 842 */
775 843 public function check_manage_permissions(): bool {
844 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
845 + }
846 +
847 + /**
848 + * Check permissions for credential-managing operations.
849 + *
850 + * Writing provider API keys, disconnecting Google (a server-side token
851 + * revoke) and running live connection tests manage the site's third-party
852 + * credentials, so they require an administrator — `thinkrank_settings` is
853 + * delegatable to non-admin roles through the Role Manager. Mirrors the
854 + * pattern used by the AI-insights settings writes.
855 + *
856 + * @since 1.29.0
857 + * @return bool Permission status
858 + */
859 + public function check_credential_permissions(): bool {
776 860 return current_user_can('manage_options');
777 861 }
778 862
779 863 /**
780 - * Verify GA4 tracking
781 - * Following ThinkRank API response patterns
864 + * Fingerprint the currently connected Google account.
782 865 *
866 + * Prefers the refresh token: it is issued once per authorization grant and
867 + * survives every access-token rotation, so the cache stays warm for a whole
868 + * connection but changes the moment a different account authorizes. Falls
869 + * back to the access token when no refresh token was granted, which merely
870 + * shortens the effective cache life to one token lifetime.
871 + *
872 + * @since 1.28.0
873 + * @return string Non-reversible fingerprint, empty string when disconnected.
874 + */
875 + private function get_google_account_fingerprint(): string {
876 + $token = $this->settings->get('google_refresh_token', '')
877 + ?: $this->settings->get('google_access_token', '');
878 +
879 + return empty($token) ? '' : md5((string) $token);
880 + }
881 +
882 + /**
883 + * Drop the cached Search Console property list.
884 + *
885 + * Public and static so the OAuth paths — which run outside this controller
886 + * and after the credentials are gone — can invalidate the list on connect,
887 + * disconnect and revoke.
888 + *
889 + * @since 1.28.0
890 + * @return void
891 + */
892 + public static function purge_search_console_sites_cache(): void {
893 + delete_transient(self::SITES_CACHE_KEY);
894 + }
895 +
896 + /**
897 + * Get Search Console Sites
898 + *
783 899 * @since 1.0.0
784 900 * @param WP_REST_Request $request Request object
785 - * @return WP_REST_Response|WP_Error Response object
901 + * @return WP_REST_Response Response object
902 + *
903 + * @throws \Exception On failure.
786 904 */
787 - public function verify_ga4_tracking(WP_REST_Request $request): WP_REST_Response|WP_Error {
905 + public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
788 906 try {
789 - $measurement_id = $request->get_param('measurement_id');
907 + // Ensure Analytics_Manager is loaded for proactive token refresh
908 + if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
909 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
910 + }
790 911
791 - if (empty($measurement_id)) {
792 - return new WP_Error(
793 - 'missing_measurement_id',
794 - 'Measurement ID is required',
795 - ['status' => 400]
796 - );
912 + // Proactively refresh token if expired — prevents 401 errors on initial load
913 + \ThinkRank\SEO\Analytics_Manager::ensure_fresh_token();
914 +
915 + // Get access token (now guaranteed fresh if refresh_token is available)
916 + $access_token = $this->settings->get('google_access_token');
917 + $api_key = $this->settings->get('google_search_console_api_key');
918 +
919 + if (empty($access_token)) {
920 + return new WP_REST_Response([
921 + 'success' => false,
922 + 'message' => 'Google account not connected'
923 + ], 401);
797 924 }
798 925
799 - // Load tracking manager
800 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
801 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
926 + // The verified-sites list changes rarely but costs a live Google
927 + // round-trip — serve from a 30-minute transient so the Google
928 + // Services screen doesn't hit Google on every render. The cache is
929 + // only honoured for the account that wrote it: reconnecting as a
930 + // different Google account must never hand back the previous
931 + // account's properties, which reads as "my site is missing".
932 + $account = $this->get_google_account_fingerprint();
933 + $cached_sites = get_transient(self::SITES_CACHE_KEY);
934 +
935 + if (
936 + !$request->get_param('refresh')
937 + && is_array($cached_sites)
938 + && isset($cached_sites['account'], $cached_sites['payload'])
939 + && hash_equals($account, (string) $cached_sites['account'])
940 + ) {
941 + return new WP_REST_Response($cached_sites['payload'], 200);
802 942 }
803 943
804 - $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
805 - $verification_result = $tracking_manager->verify_tracking($measurement_id);
944 + // Initialize Search Console Client
945 + if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) {
946 + require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php';
947 + }
806 948
807 - return new WP_REST_Response([
808 - 'success' => true,
809 - 'data' => $verification_result,
810 - 'message' => 'Tracking verification completed'
811 - ], 200);
949 + // Ensure Analytics_Manager is loaded for token refresh
950 + if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
951 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
952 + }
812 953
813 - } catch (\Exception $e) {
814 - return new WP_Error(
815 - 'verification_failed',
816 - 'Tracking verification failed: ' . $e->getMessage(),
817 - ['status' => 500]
954 + // We need a client that can use the access token
955 + $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
956 + $api_key ?: '',
957 + 30,
958 + $access_token
818 959 );
819 - }
820 - }
821 960
822 - /**
823 - * Detect GA4 conflicts
824 - * Following ThinkRank API response patterns
825 - *
826 - * @since 1.0.0
827 - * @param WP_REST_Request $request Request object
828 - * @return WP_REST_Response|WP_Error Response object
829 - */
830 - public function detect_ga4_conflicts(WP_REST_Request $request): WP_REST_Response|WP_Error {
831 - try {
832 - // Load tracking manager
833 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
834 - require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
961 + $max_retries = 1;
962 + $retry_count = 0;
963 + $sites_data = [];
964 +
965 + while ($retry_count <= $max_retries) {
966 + try {
967 + // Fetch sites
968 + $sites_data = $client->list_sites();
969 + break; // Success
970 + } catch (\Exception $e) {
971 + // Check for 401 error
972 + if ($e->getCode() === 401 && $retry_count < $max_retries) {
973 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
974 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
975 + error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
976 + }
977 +
978 + // Initialize Analytics Manager to handle token refresh
979 + $analytics_manager = new \ThinkRank\SEO\Analytics_Manager();
980 + $analytics_manager->refresh_access_token(true); // Force refresh
981 +
982 + // Get new token
983 + $new_access_token = $this->settings->get('google_access_token');
984 + // Update client with new token
985 + $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
986 + $api_key ?: '',
987 + 30,
988 + $new_access_token
989 + );
990 +
991 + $retry_count++;
992 + continue;
993 + }
994 +
995 + throw $e;
996 + }
835 997 }
836 998
837 - $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
838 - $conflicts = $tracking_manager->detect_existing_tracking();
999 + $sites = [];
1000 + if (isset($sites_data['siteEntry']) && is_array($sites_data['siteEntry'])) {
1001 + foreach ($sites_data['siteEntry'] as $site) {
1002 + $sites[] = [
1003 + 'siteUrl' => $site['siteUrl'] ?? '',
1004 + 'permissionLevel' => $site['permissionLevel'] ?? 'siteOwner'
1005 + ];
1006 + }
1007 + }
839 1008
840 - return new WP_REST_Response([
1009 + $payload = [
841 1010 'success' => true,
842 1011 'data' => [
843 - 'conflicts' => $conflicts,
844 - 'has_conflicts' => !empty($conflicts)
1012 + 'sites' => $sites
845 1013 ],
846 - 'message' => 'Conflict detection completed'
847 - ], 200);
1014 + 'message' => 'Search Console sites retrieved successfully'
1015 + ];
848 1016
1017 + // Cache successes only — errors must stay retryable. Recompute the
1018 + // fingerprint: the 401 retry above may have rotated the access
1019 + // token, and the cache must be stamped with the account it came
1020 + // from, not the one we started the request with.
1021 + if (!empty($sites)) {
1022 + set_transient(
1023 + self::SITES_CACHE_KEY,
1024 + [
1025 + 'account' => $this->get_google_account_fingerprint(),
1026 + 'payload' => $payload,
1027 + ],
1028 + 30 * MINUTE_IN_SECONDS
1029 + );
1030 + }
1031 +
1032 + return new WP_REST_Response($payload, 200);
849 1033 } catch (\Exception $e) {
850 - return new WP_Error(
851 - 'conflict_detection_failed',
852 - 'Conflict detection failed: ' . $e->getMessage(),
853 - ['status' => 500]
854 - );
1034 + $code = $e->getCode();
1035 + $status = ($code >= 400 && $code < 600) ? $code : 500;
1036 +
1037 + return new WP_REST_Response([
1038 + 'success' => false,
1039 + 'message' => 'Failed to retrieve Search Console sites: ' . $e->getMessage()
1040 + ], $status);
855 1041 }
856 1042 }
857 1043 }