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 +628 -137 1.0.12.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,56 +397,419 @@
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 *
415 + * Makes a real API call to Google's PageSpeed Insights API to verify
416 + * that the API key is valid and has proper permissions.
417 + *
393 418 * @since 1.0.0
394 419 * @param string $api_key API key to test
395 - * @return array Test result
420 + * @return array Test result with status and message
396 421 */
397 422 private function test_google_analytics(string $api_key): array {
398 - // Simple validation test - in production this would make actual API call
399 - if (strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
400 - return ['status' => 'error', 'message' => 'Invalid Google Analytics API key format'];
423 + // Basic format validation
424 + if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
425 + return [
426 + 'status' => 'error',
427 + 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
428 + ];
401 429 }
402 430
403 - return ['status' => 'configured', 'message' => 'Google Analytics API key is configured and format is valid'];
431 + // Make a real API call to test the key
432 + // Using PageSpeed Insights API as it uses the same API key and has a simple test endpoint
433 + $test_url = add_query_arg([
434 + 'url' => 'https://example.com/',
435 + 'key' => $api_key,
436 + 'category' => 'performance',
437 + 'strategy' => 'mobile'
438 + ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
439 +
440 + // Get timeout setting from settings or use default
441 + $timeout = absint($this->settings->get('api_timeout') ?? 30);
442 +
443 + $response = wp_remote_get($test_url, [
444 + 'timeout' => $timeout,
445 + 'headers' => [
446 + 'Accept' => 'application/json'
447 + ],
448 + 'sslverify' => true
449 + ]);
450 +
451 + // Handle network/connection errors
452 + if (is_wp_error($response)) {
453 + return [
454 + 'status' => 'error',
455 + 'message' => 'Connection failed: ' . $response->get_error_message()
456 + ];
457 + }
458 +
459 + $response_code = wp_remote_retrieve_response_code($response);
460 + $response_body = wp_remote_retrieve_body($response);
461 +
462 + // Handle HTTP errors
463 + if ($response_code === 400) {
464 + $error_data = json_decode($response_body, true);
465 + $error_message = $error_data['error']['message'] ?? 'Bad request';
466 +
467 + return [
468 + 'status' => 'error',
469 + 'message' => 'API key validation failed: ' . $error_message
470 + ];
471 + }
472 +
473 + if ($response_code === 403) {
474 + $error_data = json_decode($response_body, true);
475 + $error_message = $error_data['error']['message'] ?? 'Access forbidden';
476 +
477 + // Check if it's an API key issue
478 + if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
479 + return [
480 + 'status' => 'error',
481 + 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
482 + ];
483 + }
484 +
485 + return [
486 + 'status' => 'error',
487 + 'message' => 'Access denied: ' . $error_message
488 + ];
489 + }
490 +
491 + if ($response_code === 429) {
492 + return [
493 + 'status' => 'configured',
494 + 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
495 + ];
496 + }
497 +
498 + if ($response_code !== 200) {
499 + return [
500 + 'status' => 'error',
501 + 'message' => 'API request failed with status code: ' . $response_code
502 + ];
503 + }
504 +
505 + // Validate response body
506 + $data = json_decode($response_body, true);
507 +
508 + if (json_last_error() !== JSON_ERROR_NONE) {
509 + return [
510 + 'status' => 'error',
511 + 'message' => 'Invalid API response format'
512 + ];
513 + }
514 +
515 + // Check if response has expected structure
516 + if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
517 + return [
518 + 'status' => 'error',
519 + 'message' => 'Unexpected API response structure'
520 + ];
521 + }
522 +
523 + // Success - API key is valid and working
524 + return [
525 + 'status' => 'configured',
526 + 'message' => 'Google API key is valid and working correctly'
527 + ];
404 528 }
405 529
406 530 /**
407 531 * Test Google Search Console API connection
408 532 *
533 + * Makes a real API call to Google's PageSpeed Insights API to verify
534 + * that the API key is valid and has proper permissions.
535 + *
409 536 * @since 1.0.0
410 537 * @param string $api_key API key to test
411 - * @return array Test result
538 + * @return array Test result with status and message
412 539 */
413 540 private function test_search_console(string $api_key): array {
414 - // Simple validation test - in production this would make actual API call
415 - if (strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
416 - return ['status' => 'error', 'message' => 'Invalid Search Console API key format'];
541 + // Basic format validation
542 + if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
543 + return [
544 + 'status' => 'error',
545 + 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
546 + ];
417 547 }
418 548
419 - return ['status' => 'configured', 'message' => 'Search Console API key is configured and format is valid'];
549 + // Make a real API call to test the key
550 + // Using PageSpeed Insights API as it uses the same API key format
551 + $test_url = add_query_arg([
552 + 'url' => 'https://example.com/',
553 + 'key' => $api_key,
554 + 'category' => 'seo',
555 + 'strategy' => 'desktop'
556 + ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
557 +
558 + // Get timeout setting from settings or use default
559 + $timeout = absint($this->settings->get('api_timeout') ?? 30);
560 +
561 + $response = wp_remote_get($test_url, [
562 + 'timeout' => $timeout,
563 + 'headers' => [
564 + 'Accept' => 'application/json'
565 + ],
566 + 'sslverify' => true
567 + ]);
568 +
569 + // Handle network/connection errors
570 + if (is_wp_error($response)) {
571 + return [
572 + 'status' => 'error',
573 + 'message' => 'Connection failed: ' . $response->get_error_message()
574 + ];
575 + }
576 +
577 + $response_code = wp_remote_retrieve_response_code($response);
578 + $response_body = wp_remote_retrieve_body($response);
579 +
580 + // Handle HTTP errors
581 + if ($response_code === 400) {
582 + $error_data = json_decode($response_body, true);
583 + $error_message = $error_data['error']['message'] ?? 'Bad request';
584 +
585 + return [
586 + 'status' => 'error',
587 + 'message' => 'API key validation failed: ' . $error_message
588 + ];
589 + }
590 +
591 + if ($response_code === 403) {
592 + $error_data = json_decode($response_body, true);
593 + $error_message = $error_data['error']['message'] ?? 'Access forbidden';
594 +
595 + // Check if it's an API key issue
596 + if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
597 + return [
598 + 'status' => 'error',
599 + 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
600 + ];
601 + }
602 +
603 + return [
604 + 'status' => 'error',
605 + 'message' => 'Access denied: ' . $error_message
606 + ];
607 + }
608 +
609 + if ($response_code === 429) {
610 + return [
611 + 'status' => 'configured',
612 + 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
613 + ];
614 + }
615 +
616 + if ($response_code !== 200) {
617 + return [
618 + 'status' => 'error',
619 + 'message' => 'API request failed with status code: ' . $response_code
620 + ];
621 + }
622 +
623 + // Validate response body
624 + $data = json_decode($response_body, true);
625 +
626 + if (json_last_error() !== JSON_ERROR_NONE) {
627 + return [
628 + 'status' => 'error',
629 + 'message' => 'Invalid API response format'
630 + ];
631 + }
632 +
633 + // Check if response has expected structure
634 + if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
635 + return [
636 + 'status' => 'error',
637 + 'message' => 'Unexpected API response structure'
638 + ];
639 + }
640 +
641 + // Success - API key is valid and working
642 + return [
643 + 'status' => 'configured',
644 + 'message' => 'Google API key is valid and working correctly'
645 + ];
420 646 }
421 647
422 648 /**
423 - * Test Google PageSpeed API connection
649 + * Test Google PageSpeed API connection using OAuth token
424 650 *
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.
653 + *
425 654 * @since 1.0.0
426 - * @param string $api_key API key to test
427 - * @return array Test result
655 + * @param string $access_token OAuth access token
656 + * @return array Test result with status and message
428 657 */
429 - private function test_pagespeed(string $api_key): array {
430 - // Simple validation test - in production this would make actual API call
431 - if (strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
432 - return ['status' => 'error', 'message' => 'Invalid PageSpeed Insights API key format'];
658 + private function test_pagespeed_oauth(string $access_token): array {
659 + if (empty($access_token)) {
660 + return [
661 + 'status' => 'error',
662 + 'message' => 'Google account not connected. Please connect your Google account to use PageSpeed Insights.'
663 + ];
433 664 }
434 665
435 - return ['status' => 'configured', 'message' => 'PageSpeed Insights API key is configured and format is valid'];
666 + // Make a real API call to test the OAuth token with PageSpeed API
667 + $test_url = add_query_arg([
668 + 'url' => 'https://example.com/',
669 + 'category' => 'performance',
670 + 'strategy' => 'mobile'
671 + ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
672 +
673 + // Get timeout setting from settings or use default
674 + $timeout = absint($this->settings->get('api_timeout') ?? 30);
675 +
676 + $response = wp_remote_get($test_url, [
677 + 'timeout' => $timeout,
678 + 'headers' => [
679 + 'Accept' => 'application/json',
680 + 'Authorization' => 'Bearer ' . $access_token
681 + ],
682 + 'sslverify' => true
683 + ]);
684 +
685 + // Handle network/connection errors
686 + if (is_wp_error($response)) {
687 + return [
688 + 'status' => 'error',
689 + 'message' => 'Connection failed: ' . $response->get_error_message()
690 + ];
691 + }
692 +
693 + $response_code = wp_remote_retrieve_response_code($response);
694 + $response_body = wp_remote_retrieve_body($response);
695 +
696 + // Handle HTTP errors
697 + if ($response_code === 401) {
698 + return [
699 + 'status' => 'error',
700 + 'message' => 'Google account token is expired or invalid. Please reconnect your Google account.'
701 + ];
702 + }
703 +
704 + if ($response_code === 403) {
705 + $error_data = json_decode($response_body, true);
706 + $error_message = $error_data['error']['message'] ?? 'Access forbidden';
707 + return [
708 + 'status' => 'error',
709 + 'message' => 'Access denied: ' . $error_message
710 + ];
711 + }
712 +
713 + if ($response_code === 429) {
714 + return [
715 + 'status' => 'configured',
716 + 'message' => 'API rate limit exceeded. The account is valid but you\'ve reached the quota limit.'
717 + ];
718 + }
719 +
720 + if ($response_code !== 200) {
721 + return [
722 + 'status' => 'error',
723 + 'message' => 'API request failed with status code: ' . $response_code
724 + ];
725 + }
726 +
727 + // Validate response body
728 + $data = json_decode($response_body, true);
729 +
730 + if (json_last_error() !== JSON_ERROR_NONE) {
731 + return [
732 + 'status' => 'error',
733 + 'message' => 'Invalid API response format'
734 + ];
735 + }
736 +
737 + // Check if response has expected structure
738 + if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
739 + return [
740 + 'status' => 'error',
741 + 'message' => 'Unexpected API response structure'
742 + ];
743 + }
744 +
745 + // Success - OAuth token is valid and working with PageSpeed API
746 + return [
747 + 'status' => 'configured',
748 + 'message' => 'Google PageSpeed Insights connected successfully via Google OAuth'
749 + ];
436 750 }
437 751
438 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 + /**
439 812 * Get settings arguments for REST API
440 813 *
441 814 * @since 1.0.0
442 815 * @return array Settings arguments
@@ -457,9 +830,9 @@
457 830 * @since 1.0.0
458 831 * @return bool Permission status
459 832 */
460 833 public function check_read_permissions(): bool {
461 - return current_user_can('manage_options');
834 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
462 835 }
463 836
464 837 /**
465 838 * Check manage permissions
@@ -467,86 +840,204 @@
467 840 * @since 1.0.0
468 841 * @return bool Permission status
469 842 */
470 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 {
471 860 return current_user_can('manage_options');
472 861 }
473 862
474 863 /**
475 - * Verify GA4 tracking
476 - * Following ThinkRank API response patterns
864 + * Fingerprint the currently connected Google account.
477 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 + *
478 899 * @since 1.0.0
479 900 * @param WP_REST_Request $request Request object
480 - * @return WP_REST_Response|WP_Error Response object
901 + * @return WP_REST_Response Response object
902 + *
903 + * @throws \Exception On failure.
481 904 */
482 - 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 {
483 906 try {
484 - $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 + }
485 911
486 - if (empty($measurement_id)) {
487 - return new WP_Error(
488 - 'missing_measurement_id',
489 - 'Measurement ID is required',
490 - ['status' => 400]
491 - );
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);
492 924 }
493 925
494 - // Load tracking manager
495 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
496 - 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);
497 942 }
498 943
499 - $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
500 - $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 + }
501 948
502 - return new WP_REST_Response([
503 - 'success' => true,
504 - 'data' => $verification_result,
505 - 'message' => 'Tracking verification completed'
506 - ], 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 + }
507 953
508 - } catch (\Exception $e) {
509 - return new WP_Error(
510 - 'verification_failed',
511 - 'Tracking verification failed: ' . $e->getMessage(),
512 - ['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
513 959 );
514 - }
515 - }
516 960
517 - /**
518 - * Detect GA4 conflicts
519 - * Following ThinkRank API response patterns
520 - *
521 - * @since 1.0.0
522 - * @param WP_REST_Request $request Request object
523 - * @return WP_REST_Response|WP_Error Response object
524 - */
525 - public function detect_ga4_conflicts(WP_REST_Request $request): WP_REST_Response|WP_Error {
526 - try {
527 - // Load tracking manager
528 - if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
529 - 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 + }
530 997 }
531 998
532 - $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
533 - $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 + }
534 1008
535 - return new WP_REST_Response([
1009 + $payload = [
536 1010 'success' => true,
537 1011 'data' => [
538 - 'conflicts' => $conflicts,
539 - 'has_conflicts' => !empty($conflicts)
1012 + 'sites' => $sites
540 1013 ],
541 - 'message' => 'Conflict detection completed'
542 - ], 200);
1014 + 'message' => 'Search Console sites retrieved successfully'
1015 + ];
543 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);
544 1033 } catch (\Exception $e) {
545 - return new WP_Error(
546 - 'conflict_detection_failed',
547 - 'Conflict detection failed: ' . $e->getMessage(),
548 - ['status' => 500]
549 - );
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);
550 1041 }
551 1042 }
552 1043 }