PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 releases
thinkrank / includes / api / class-integrations-endpoint.php

class-integrations-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/api/class-integrations-endpoint.php

1,044 lines 36.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Integrations API Endpoints Class
5 *
6 * Provides REST API endpoints for Google API integrations management
7 * following the same pattern as Site Identity and Social Media endpoints.
8 *
9 * @package ThinkRank\API
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\API;
16
17 use WP_REST_Controller;
18 use WP_REST_Server;
19 use WP_REST_Request;
20 use WP_REST_Response;
21 use WP_Error;
22 use ThinkRank\Core\Settings;
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Integrations API Endpoints Class
31 *
32 * Handles Google API keys and integration settings management
33 * following ThinkRank patterns from working endpoints.
34 *
35 * @since 1.0.0
36 */
37 class Integrations_Endpoint extends WP_REST_Controller {
38
39 /**
40 * API namespace
41 *
42 * @since 1.0.0
43 * @var string
44 */
45 protected $namespace = 'thinkrank/v1';
46
47 /**
48 * REST base
49 *
50 * @since 1.0.0
51 * @var string
52 */
53 protected $rest_base = 'integrations';
54
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 /**
71 * Settings instance
72 *
73 * @since 1.0.0
74 * @var Settings
75 */
76 private Settings $settings;
77
78 /**
79 * Constructor
80 *
81 * @since 1.0.0
82 */
83 public function __construct() {
84 $this->settings = Settings::instance();
85 }
86
87 /**
88 * Register API routes
89 *
90 * @since 1.0.0
91 */
92 public function register_routes(): void {
93 // Integrations settings management
94 register_rest_route(
95 $this->namespace,
96 '/' . $this->rest_base . '/settings',
97 [
98 [
99 'methods' => 'GET',
100 'callback' => [$this, 'get_settings'],
101 'permission_callback' => [$this, 'check_read_permissions']
102 ],
103 [
104 'methods' => 'POST',
105 'callback' => [$this, 'update_settings'],
106 'permission_callback' => [$this, 'check_credential_permissions'],
107 'args' => $this->get_settings_args()
108 ]
109 ]
110 );
111
112 // Test Google API connections
113 register_rest_route(
114 $this->namespace,
115 '/' . $this->rest_base . '/test-connections',
116 [
117 [
118 'methods' => 'POST',
119 'callback' => [$this, 'test_connections'],
120 'permission_callback' => [$this, 'check_credential_permissions']
121 ]
122 ]
123 );
124
125 // Get Search Console Sites
126 register_rest_route(
127 $this->namespace,
128 '/' . $this->rest_base . '/search-console/sites',
129 [
130 [
131 'methods' => 'GET',
132 'callback' => [$this, 'get_search_console_sites'],
133 'permission_callback' => [$this, 'check_manage_permissions'],
134 'args' => [
135 'refresh' => [
136 'description' => 'Bypass the cached property list and re-query Google.',
137 'type' => 'boolean',
138 'default' => false,
139 ],
140 ],
141 ]
142 ]
143 );
144
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.
155 }
156
157 /**
158 * Get integrations settings
159 *
160 * @since 1.0.0
161 *
162 * @param WP_REST_Request $request Request object
163 * @return WP_REST_Response Response object
164 */
165 public function get_settings(WP_REST_Request $request): WP_REST_Response {
166 try {
167 $settings = $this->get_integrations_settings();
168
169 return new WP_REST_Response([
170 'success' => true,
171 'data' => [
172 'settings' => $settings
173 ],
174 'message' => 'Integrations settings retrieved successfully'
175 ], 200);
176 } catch (\Exception $e) {
177 return new WP_REST_Response([
178 'success' => false,
179 'message' => 'Failed to retrieve integrations settings: ' . $e->getMessage()
180 ], 500);
181 }
182 }
183
184 /**
185 * Update integrations settings
186 *
187 * @since 1.0.0
188 *
189 * @param WP_REST_Request $request Request object
190 * @return WP_REST_Response Response object
191 */
192 public function update_settings(WP_REST_Request $request): WP_REST_Response {
193 try {
194 $settings = $request->get_param('settings');
195
196 if (empty($settings) || !is_array($settings)) {
197 return new WP_REST_Response([
198 'success' => false,
199 'message' => 'Invalid settings data provided'
200 ], 400);
201 }
202
203 // Sanitize and save settings
204 $sanitized_settings = $this->sanitize_settings($settings);
205 $success = $this->save_integrations_settings($sanitized_settings);
206
207 if ($success) {
208 return new WP_REST_Response([
209 'success' => true,
210 'data' => [
211 'settings' => $this->get_integrations_settings()
212 ],
213 'message' => 'Integrations settings saved successfully'
214 ], 200);
215 } else {
216 return new WP_REST_Response([
217 'success' => false,
218 'message' => 'Failed to save integrations settings'
219 ], 500);
220 }
221 } catch (\Exception $e) {
222 return new WP_REST_Response([
223 'success' => false,
224 'message' => 'Failed to update integrations settings: ' . $e->getMessage()
225 ], 500);
226 }
227 }
228
229 /**
230 * Test Google API connections
231 *
232 * @since 1.0.0
233 *
234 * @param WP_REST_Request $request Request object
235 * @return WP_REST_Response Response object
236 */
237 public function test_connections(WP_REST_Request $request): WP_REST_Response {
238 try {
239 // Get raw, unmasked API keys directly from Settings class for testing
240 $analytics_key = $this->settings->get('google_analytics_api_key');
241 $search_console_key = $this->settings->get('google_search_console_api_key');
242 $pagespeed_key = $this->settings->get('google_pagespeed_api_key');
243
244 $results = [];
245
246 // Test Google Analytics API
247 if (!empty($analytics_key)) {
248 $results['google_analytics'] = $this->test_google_analytics($analytics_key);
249 } else {
250 $results['google_analytics'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
251 }
252
253 // Test Search Console API
254 if (!empty($search_console_key)) {
255 $results['search_console'] = $this->test_search_console($search_console_key);
256 } else {
257 $results['search_console'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
258 }
259
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);
264 } else {
265 $results['pagespeed'] = ['status' => 'not_configured', 'message' => 'Google account not connected'];
266 }
267
268 return new WP_REST_Response([
269 'success' => true,
270 'data' => $results,
271 'message' => 'Connection tests completed'
272 ], 200);
273 } catch (\Exception $e) {
274 return new WP_REST_Response([
275 'success' => false,
276 'message' => 'Connection test failed: ' . $e->getMessage()
277 ], 500);
278 }
279 }
280
281 /**
282 * Get integrations settings from Settings class (with encryption)
283 *
284 * @since 1.0.0
285 * @return array Settings array
286 */
287 private function get_integrations_settings(): array {
288 $settings = [];
289
290 // Get encrypted API keys
291 $settings['google_analytics_api_key'] = $this->settings->get('google_analytics_api_key');
292 $settings['google_search_console_api_key'] = $this->settings->get('google_search_console_api_key');
293 $settings['google_pagespeed_api_key'] = $this->settings->get('google_pagespeed_api_key');
294
295 // Get other integration settings (let Settings class handle defaults)
296 $settings['api_timeout'] = $this->settings->get('api_timeout');
297 $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting');
298 $settings['cache_duration'] = $this->settings->get('cache_duration');
299 $settings['auto_test_connections'] = $this->settings->get('auto_test_connections');
300 $settings['retry_failed_requests'] = $this->settings->get('retry_failed_requests');
301 $settings['google_account_connected'] = $this->settings->get('google_account_connected');
302
303 // Mask API keys for security (like OpenAI/Claude keys)
304 $settings['google_analytics_api_key'] = $this->mask_api_key($settings['google_analytics_api_key']);
305 $settings['google_search_console_api_key'] = $this->mask_api_key($settings['google_search_console_api_key']);
306 $settings['google_pagespeed_api_key'] = $this->mask_api_key($settings['google_pagespeed_api_key']);
307
308 return $settings;
309 }
310
311 /**
312 * Save integrations settings using Settings class (with encryption)
313 *
314 * @since 1.0.0
315 * @param array $settings Settings to save
316 * @return bool Success status
317 */
318 private function save_integrations_settings(array $settings): bool {
319 $success = true;
320
321 // Save each setting individually using the Settings class
322 // This ensures proper encryption for API keys
323 foreach ($settings as $key => $value) {
324 if (!$this->settings->set($key, $value)) {
325 $success = false;
326 // Setting save failed - error details available through settings manager
327 }
328 }
329
330 return $success;
331 }
332
333 /**
334 * Sanitize settings data
335 *
336 * @since 1.0.0
337 * @param array $settings Raw settings
338 * @return array Sanitized settings
339 */
340 private function sanitize_settings(array $settings): array {
341 $sanitized = [];
342
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 }
350 }
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 }
363 }
364
365 $booleans = [
366 'enable_rate_limiting',
367 'auto_test_connections',
368 'retry_failed_requests',
369 ];
370
371 foreach ($booleans as $key) {
372 if (array_key_exists($key, $settings)) {
373 $sanitized[$key] = (bool) $settings[$key];
374 }
375 }
376
377 return $sanitized;
378 }
379
380 /**
381 * Mask API key for security display (XXX pattern)
382 *
383 * @since 1.0.0
384 * @param string $api_key API key to mask
385 * @return string Masked API key or empty string
386 */
387 private function mask_api_key(string $api_key): string {
388 if (empty($api_key)) {
389 return '';
390 }
391
392 // Show first 6 characters + XXXX suffix (consistent with placeholders)
393 if (strlen($api_key) > 10) {
394 return substr($api_key, 0, 6) . 'XXXX';
395 }
396
397 return 'XXXX';
398 }
399
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 /**
413 * Test Google Analytics API connection
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 *
418 * @since 1.0.0
419 * @param string $api_key API key to test
420 * @return array Test result with status and message
421 */
422 private function test_google_analytics(string $api_key): array {
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 ];
429 }
430
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 ];
528 }
529
530 /**
531 * Test Google Search Console API connection
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 *
536 * @since 1.0.0
537 * @param string $api_key API key to test
538 * @return array Test result with status and message
539 */
540 private function test_search_console(string $api_key): array {
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 ];
547 }
548
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 ];
646 }
647
648 /**
649 * Test Google PageSpeed API connection using OAuth token
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 *
654 * @since 1.0.0
655 * @param string $access_token OAuth access token
656 * @return array Test result with status and message
657 */
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 ];
664 }
665
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 ];
750 }
751
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 /**
812 * Get settings arguments for REST API
813 *
814 * @since 1.0.0
815 * @return array Settings arguments
816 */
817 private function get_settings_args(): array {
818 return [
819 'settings' => [
820 'required' => true,
821 'type' => 'object',
822 'description' => 'Integrations settings object'
823 ]
824 ];
825 }
826
827 /**
828 * Check read permissions
829 *
830 * @since 1.0.0
831 * @return bool Permission status
832 */
833 public function check_read_permissions(): bool {
834 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
835 }
836
837 /**
838 * Check manage permissions
839 *
840 * @since 1.0.0
841 * @return bool Permission status
842 */
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 {
860 return current_user_can('manage_options');
861 }
862
863 /**
864 * Fingerprint the currently connected Google account.
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 *
899 * @since 1.0.0
900 * @param WP_REST_Request $request Request object
901 * @return WP_REST_Response Response object
902 *
903 * @throws \Exception On failure.
904 */
905 public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
906 try {
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 }
911
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);
924 }
925
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);
942 }
943
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 }
948
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 }
953
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
959 );
960
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 }
997 }
998
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 }
1008
1009 $payload = [
1010 'success' => true,
1011 'data' => [
1012 'sites' => $sites
1013 ],
1014 'message' => 'Search Console sites retrieved successfully'
1015 ];
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);
1033 } catch (\Exception $e) {
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);
1041 }
1042 }
1043 }
1044