PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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
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 2.1.1, at includes/api/class-integrations-endpoint.php

1,183 lines 42.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 // Verify GA4 tracking
126 register_rest_route(
127 $this->namespace,
128 '/' . $this->rest_base . '/verify-ga4-tracking',
129 [
130 [
131 'methods' => 'POST',
132 'callback' => [$this, 'verify_ga4_tracking'],
133 'permission_callback' => [$this, 'check_manage_permissions'],
134 'args' => [
135 'measurement_id' => [
136 // Optional, and an empty string is meaningful:
137 // verify_tracking() then discovers the ID from the
138 // live homepage. A `required` + `pattern` arg
139 // rejected that at the REST layer before the
140 // handler ran, which is why verification was
141 // unreachable on sites with no stored ID (#250).
142 'required' => false,
143 'type' => 'string',
144 'default' => '',
145 // No regex delimiters — WP's REST validator wraps the
146 // pattern in its own (#...#u), so a leading/trailing
147 // slash would require literal slashes in the value.
148 // The empty alternative keeps discovery reachable
149 // while still rejecting a malformed ID.
150 'pattern' => '^(G-[A-Z0-9]{10})?$',
151 'sanitize_callback' => 'sanitize_text_field',
152 'description' => 'GA4 Measurement ID in format G-XXXXXXXXXX. Omit or leave empty to auto-detect the ID from the site homepage.'
153 ]
154 ]
155 ]
156 ]
157 );
158
159 // Detect GA4 conflicts
160 register_rest_route(
161 $this->namespace,
162 '/' . $this->rest_base . '/detect-ga4-conflicts',
163 [
164 [
165 'methods' => 'GET',
166 'callback' => [$this, 'detect_ga4_conflicts'],
167 'permission_callback' => [$this, 'check_read_permissions']
168 ]
169 ]
170 );
171
172 // Get Search Console Sites
173 register_rest_route(
174 $this->namespace,
175 '/' . $this->rest_base . '/search-console/sites',
176 [
177 [
178 'methods' => 'GET',
179 'callback' => [$this, 'get_search_console_sites'],
180 'permission_callback' => [$this, 'check_manage_permissions'],
181 'args' => [
182 'refresh' => [
183 'description' => 'Bypass the cached property list and re-query Google.',
184 'type' => 'boolean',
185 'default' => false,
186 ],
187 ],
188 ]
189 ]
190 );
191
192 // Disconnect Google Account
193 register_rest_route($this->namespace, '/integrations/google/disconnect', [
194 'methods' => WP_REST_Server::CREATABLE,
195 'callback' => [$this, 'disconnect_google_account'],
196 'permission_callback' => [$this, 'check_credential_permissions']
197 ]);
198
199 // Note: there is no save-google-token route. Tokens are swapped
200 // server-to-server in Google_OAuth_Proxy and never pass through the
201 // browser, so there is nothing for the SPA to hand back.
202 }
203
204 /**
205 * Get integrations settings
206 *
207 * @since 1.0.0
208 *
209 * @param WP_REST_Request $request Request object
210 * @return WP_REST_Response Response object
211 */
212 public function get_settings(WP_REST_Request $request): WP_REST_Response {
213 try {
214 $settings = $this->get_integrations_settings();
215
216 return new WP_REST_Response([
217 'success' => true,
218 'data' => [
219 'settings' => $settings
220 ],
221 'message' => 'Integrations settings retrieved successfully'
222 ], 200);
223 } catch (\Exception $e) {
224 return new WP_REST_Response([
225 'success' => false,
226 'message' => 'Failed to retrieve integrations settings: ' . $e->getMessage()
227 ], 500);
228 }
229 }
230
231 /**
232 * Update integrations settings
233 *
234 * @since 1.0.0
235 *
236 * @param WP_REST_Request $request Request object
237 * @return WP_REST_Response Response object
238 */
239 public function update_settings(WP_REST_Request $request): WP_REST_Response {
240 try {
241 $settings = $request->get_param('settings');
242
243 if (empty($settings) || !is_array($settings)) {
244 return new WP_REST_Response([
245 'success' => false,
246 'message' => 'Invalid settings data provided'
247 ], 400);
248 }
249
250 // Sanitize and save settings
251 $sanitized_settings = $this->sanitize_settings($settings);
252 $success = $this->save_integrations_settings($sanitized_settings);
253
254 if ($success) {
255 return new WP_REST_Response([
256 'success' => true,
257 'data' => [
258 'settings' => $this->get_integrations_settings()
259 ],
260 'message' => 'Integrations settings saved successfully'
261 ], 200);
262 } else {
263 return new WP_REST_Response([
264 'success' => false,
265 'message' => 'Failed to save integrations settings'
266 ], 500);
267 }
268 } catch (\Exception $e) {
269 return new WP_REST_Response([
270 'success' => false,
271 'message' => 'Failed to update integrations settings: ' . $e->getMessage()
272 ], 500);
273 }
274 }
275
276 /**
277 * Test Google API connections
278 *
279 * @since 1.0.0
280 *
281 * @param WP_REST_Request $request Request object
282 * @return WP_REST_Response Response object
283 */
284 public function test_connections(WP_REST_Request $request): WP_REST_Response {
285 try {
286 // Get raw, unmasked API keys directly from Settings class for testing
287 $analytics_key = $this->settings->get('google_analytics_api_key');
288 $search_console_key = $this->settings->get('google_search_console_api_key');
289 $pagespeed_key = $this->settings->get('google_pagespeed_api_key');
290
291 $results = [];
292
293 // Test Google Analytics API
294 if (!empty($analytics_key)) {
295 $results['google_analytics'] = $this->test_google_analytics($analytics_key);
296 } else {
297 $results['google_analytics'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
298 }
299
300 // Test Search Console API
301 if (!empty($search_console_key)) {
302 $results['search_console'] = $this->test_search_console($search_console_key);
303 } else {
304 $results['search_console'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
305 }
306
307 // Test PageSpeed API (now uses OAuth access token)
308 $access_token = $this->settings->get('google_access_token');
309 if (!empty($access_token)) {
310 $results['pagespeed'] = $this->test_pagespeed_oauth($access_token);
311 } else {
312 $results['pagespeed'] = ['status' => 'not_configured', 'message' => 'Google account not connected'];
313 }
314
315 return new WP_REST_Response([
316 'success' => true,
317 'data' => $results,
318 'message' => 'Connection tests completed'
319 ], 200);
320 } catch (\Exception $e) {
321 return new WP_REST_Response([
322 'success' => false,
323 'message' => 'Connection test failed: ' . $e->getMessage()
324 ], 500);
325 }
326 }
327
328 /**
329 * Get integrations settings from Settings class (with encryption)
330 *
331 * @since 1.0.0
332 * @return array Settings array
333 */
334 private function get_integrations_settings(): array {
335 $settings = [];
336
337 // Get encrypted API keys
338 $settings['google_analytics_api_key'] = $this->settings->get('google_analytics_api_key');
339 $settings['google_search_console_api_key'] = $this->settings->get('google_search_console_api_key');
340 $settings['google_pagespeed_api_key'] = $this->settings->get('google_pagespeed_api_key');
341
342 // Get GA4 tracking settings (let Settings class handle defaults)
343 $settings['ga4_measurement_id'] = $this->settings->get('ga4_measurement_id');
344 $settings['ga4_auto_inject'] = $this->settings->get('ga4_auto_inject');
345 $settings['ga4_anonymize_ip'] = $this->settings->get('ga4_anonymize_ip');
346 $settings['ga4_exclude_admin'] = $this->settings->get('ga4_exclude_admin');
347 $settings['ga4_tracking_verified'] = $this->settings->get('ga4_tracking_verified');
348 $settings['ga4_last_verification'] = $this->settings->get('ga4_last_verification');
349
350 // Get other integration settings (let Settings class handle defaults)
351 $settings['api_timeout'] = $this->settings->get('api_timeout');
352 $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting');
353 $settings['cache_duration'] = $this->settings->get('cache_duration');
354 $settings['auto_test_connections'] = $this->settings->get('auto_test_connections');
355 $settings['retry_failed_requests'] = $this->settings->get('retry_failed_requests');
356 $settings['google_account_connected'] = $this->settings->get('google_account_connected');
357
358 // Mask API keys for security (like OpenAI/Claude keys)
359 $settings['google_analytics_api_key'] = $this->mask_api_key($settings['google_analytics_api_key']);
360 $settings['google_search_console_api_key'] = $this->mask_api_key($settings['google_search_console_api_key']);
361 $settings['google_pagespeed_api_key'] = $this->mask_api_key($settings['google_pagespeed_api_key']);
362
363 return $settings;
364 }
365
366 /**
367 * Save integrations settings using Settings class (with encryption)
368 *
369 * @since 1.0.0
370 * @param array $settings Settings to save
371 * @return bool Success status
372 */
373 private function save_integrations_settings(array $settings): bool {
374 $success = true;
375
376 // Save each setting individually using the Settings class
377 // This ensures proper encryption for API keys
378 foreach ($settings as $key => $value) {
379 if (!$this->settings->set($key, $value)) {
380 $success = false;
381 // Setting save failed - error details available through settings manager
382 }
383 }
384
385 return $success;
386 }
387
388 /**
389 * Sanitize settings data
390 *
391 * @since 1.0.0
392 * @param array $settings Raw settings
393 * @return array Sanitized settings
394 */
395 private function sanitize_settings(array $settings): array {
396 $sanitized = [];
397
398 // Sanitize API keys. Skip empty values AND the masked sentinel returned
399 // by get_integrations_settings (mask_api_key appends 'XXXX'); resubmitting
400 // the mask must not overwrite the real stored key.
401 foreach (['google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key'] as $key_field) {
402 if (!empty($settings[$key_field]) && !$this->is_masked_api_key($settings[$key_field])) {
403 $sanitized[$key_field] = sanitize_text_field($settings[$key_field]);
404 }
405 }
406
407 // A key the payload never mentioned is left alone rather than being
408 // reset to a hard-coded default. These fallbacks used to fire on every
409 // save, so a partial payload — or a setting the admin has no control
410 // for, like retry_failed_requests — silently reverted to the default a
411 // site owner had deliberately changed in code (#297).
412 $numeric = ['api_timeout', 'cache_duration'];
413
414 foreach ($numeric as $key) {
415 if (array_key_exists($key, $settings)) {
416 $sanitized[$key] = absint($settings[$key]);
417 }
418 }
419
420 $text = ['ga4_measurement_id', 'ga4_last_verification'];
421
422 foreach ($text as $key) {
423 if (array_key_exists($key, $settings)) {
424 $sanitized[$key] = sanitize_text_field($settings[$key]);
425 }
426 }
427
428 $booleans = [
429 'ga4_auto_inject',
430 'ga4_anonymize_ip',
431 'ga4_exclude_admin',
432 'ga4_tracking_verified',
433 'enable_rate_limiting',
434 'auto_test_connections',
435 'retry_failed_requests',
436 ];
437
438 foreach ($booleans as $key) {
439 if (array_key_exists($key, $settings)) {
440 $sanitized[$key] = (bool) $settings[$key];
441 }
442 }
443
444 return $sanitized;
445 }
446
447 /**
448 * Mask API key for security display (XXX pattern)
449 *
450 * @since 1.0.0
451 * @param string $api_key API key to mask
452 * @return string Masked API key or empty string
453 */
454 private function mask_api_key(string $api_key): string {
455 if (empty($api_key)) {
456 return '';
457 }
458
459 // Show first 6 characters + XXXX suffix (consistent with placeholders)
460 if (strlen($api_key) > 10) {
461 return substr($api_key, 0, 6) . 'XXXX';
462 }
463
464 return 'XXXX';
465 }
466
467 /**
468 * Whether a submitted value is the masked sentinel produced by mask_api_key
469 * (so we don't persist the mask over a real key).
470 *
471 * @since 1.0.0
472 * @param string $value Submitted value
473 * @return bool
474 */
475 private function is_masked_api_key(string $value): bool {
476 return 'XXXX' === $value || str_ends_with($value, 'XXXX');
477 }
478
479 /**
480 * Test Google Analytics API connection
481 *
482 * Makes a real API call to Google's PageSpeed Insights API to verify
483 * that the API key is valid and has proper permissions.
484 *
485 * @since 1.0.0
486 * @param string $api_key API key to test
487 * @return array Test result with status and message
488 */
489 private function test_google_analytics(string $api_key): array {
490 // Basic format validation
491 if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
492 return [
493 'status' => 'error',
494 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
495 ];
496 }
497
498 // Make a real API call to test the key
499 // Using PageSpeed Insights API as it uses the same API key and has a simple test endpoint
500 $test_url = add_query_arg([
501 'url' => 'https://example.com/',
502 'key' => $api_key,
503 'category' => 'performance',
504 'strategy' => 'mobile'
505 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
506
507 // Get timeout setting from settings or use default
508 $timeout = absint($this->settings->get('api_timeout') ?? 30);
509
510 $response = wp_remote_get($test_url, [
511 'timeout' => $timeout,
512 'headers' => [
513 'Accept' => 'application/json'
514 ],
515 'sslverify' => true
516 ]);
517
518 // Handle network/connection errors
519 if (is_wp_error($response)) {
520 return [
521 'status' => 'error',
522 'message' => 'Connection failed: ' . $response->get_error_message()
523 ];
524 }
525
526 $response_code = wp_remote_retrieve_response_code($response);
527 $response_body = wp_remote_retrieve_body($response);
528
529 // Handle HTTP errors
530 if ($response_code === 400) {
531 $error_data = json_decode($response_body, true);
532 $error_message = $error_data['error']['message'] ?? 'Bad request';
533
534 return [
535 'status' => 'error',
536 'message' => 'API key validation failed: ' . $error_message
537 ];
538 }
539
540 if ($response_code === 403) {
541 $error_data = json_decode($response_body, true);
542 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
543
544 // Check if it's an API key issue
545 if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
546 return [
547 'status' => 'error',
548 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
549 ];
550 }
551
552 return [
553 'status' => 'error',
554 'message' => 'Access denied: ' . $error_message
555 ];
556 }
557
558 if ($response_code === 429) {
559 return [
560 'status' => 'configured',
561 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
562 ];
563 }
564
565 if ($response_code !== 200) {
566 return [
567 'status' => 'error',
568 'message' => 'API request failed with status code: ' . $response_code
569 ];
570 }
571
572 // Validate response body
573 $data = json_decode($response_body, true);
574
575 if (json_last_error() !== JSON_ERROR_NONE) {
576 return [
577 'status' => 'error',
578 'message' => 'Invalid API response format'
579 ];
580 }
581
582 // Check if response has expected structure
583 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
584 return [
585 'status' => 'error',
586 'message' => 'Unexpected API response structure'
587 ];
588 }
589
590 // Success - API key is valid and working
591 return [
592 'status' => 'configured',
593 'message' => 'Google API key is valid and working correctly'
594 ];
595 }
596
597 /**
598 * Test Google Search Console API connection
599 *
600 * Makes a real API call to Google's PageSpeed Insights API to verify
601 * that the API key is valid and has proper permissions.
602 *
603 * @since 1.0.0
604 * @param string $api_key API key to test
605 * @return array Test result with status and message
606 */
607 private function test_search_console(string $api_key): array {
608 // Basic format validation
609 if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
610 return [
611 'status' => 'error',
612 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
613 ];
614 }
615
616 // Make a real API call to test the key
617 // Using PageSpeed Insights API as it uses the same API key format
618 $test_url = add_query_arg([
619 'url' => 'https://example.com/',
620 'key' => $api_key,
621 'category' => 'seo',
622 'strategy' => 'desktop'
623 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
624
625 // Get timeout setting from settings or use default
626 $timeout = absint($this->settings->get('api_timeout') ?? 30);
627
628 $response = wp_remote_get($test_url, [
629 'timeout' => $timeout,
630 'headers' => [
631 'Accept' => 'application/json'
632 ],
633 'sslverify' => true
634 ]);
635
636 // Handle network/connection errors
637 if (is_wp_error($response)) {
638 return [
639 'status' => 'error',
640 'message' => 'Connection failed: ' . $response->get_error_message()
641 ];
642 }
643
644 $response_code = wp_remote_retrieve_response_code($response);
645 $response_body = wp_remote_retrieve_body($response);
646
647 // Handle HTTP errors
648 if ($response_code === 400) {
649 $error_data = json_decode($response_body, true);
650 $error_message = $error_data['error']['message'] ?? 'Bad request';
651
652 return [
653 'status' => 'error',
654 'message' => 'API key validation failed: ' . $error_message
655 ];
656 }
657
658 if ($response_code === 403) {
659 $error_data = json_decode($response_body, true);
660 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
661
662 // Check if it's an API key issue
663 if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
664 return [
665 'status' => 'error',
666 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
667 ];
668 }
669
670 return [
671 'status' => 'error',
672 'message' => 'Access denied: ' . $error_message
673 ];
674 }
675
676 if ($response_code === 429) {
677 return [
678 'status' => 'configured',
679 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
680 ];
681 }
682
683 if ($response_code !== 200) {
684 return [
685 'status' => 'error',
686 'message' => 'API request failed with status code: ' . $response_code
687 ];
688 }
689
690 // Validate response body
691 $data = json_decode($response_body, true);
692
693 if (json_last_error() !== JSON_ERROR_NONE) {
694 return [
695 'status' => 'error',
696 'message' => 'Invalid API response format'
697 ];
698 }
699
700 // Check if response has expected structure
701 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
702 return [
703 'status' => 'error',
704 'message' => 'Unexpected API response structure'
705 ];
706 }
707
708 // Success - API key is valid and working
709 return [
710 'status' => 'configured',
711 'message' => 'Google API key is valid and working correctly'
712 ];
713 }
714
715 /**
716 * Test Google PageSpeed API connection using OAuth token
717 *
718 * Makes a real API call to Google's PageSpeed Insights API using OAuth
719 * Bearer token to verify that the account is properly connected.
720 *
721 * @since 1.0.0
722 * @param string $access_token OAuth access token
723 * @return array Test result with status and message
724 */
725 private function test_pagespeed_oauth(string $access_token): array {
726 if (empty($access_token)) {
727 return [
728 'status' => 'error',
729 'message' => 'Google account not connected. Please connect your Google account to use PageSpeed Insights.'
730 ];
731 }
732
733 // Make a real API call to test the OAuth token with PageSpeed API
734 $test_url = add_query_arg([
735 'url' => 'https://example.com/',
736 'category' => 'performance',
737 'strategy' => 'mobile'
738 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
739
740 // Get timeout setting from settings or use default
741 $timeout = absint($this->settings->get('api_timeout') ?? 30);
742
743 $response = wp_remote_get($test_url, [
744 'timeout' => $timeout,
745 'headers' => [
746 'Accept' => 'application/json',
747 'Authorization' => 'Bearer ' . $access_token
748 ],
749 'sslverify' => true
750 ]);
751
752 // Handle network/connection errors
753 if (is_wp_error($response)) {
754 return [
755 'status' => 'error',
756 'message' => 'Connection failed: ' . $response->get_error_message()
757 ];
758 }
759
760 $response_code = wp_remote_retrieve_response_code($response);
761 $response_body = wp_remote_retrieve_body($response);
762
763 // Handle HTTP errors
764 if ($response_code === 401) {
765 return [
766 'status' => 'error',
767 'message' => 'Google account token is expired or invalid. Please reconnect your Google account.'
768 ];
769 }
770
771 if ($response_code === 403) {
772 $error_data = json_decode($response_body, true);
773 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
774 return [
775 'status' => 'error',
776 'message' => 'Access denied: ' . $error_message
777 ];
778 }
779
780 if ($response_code === 429) {
781 return [
782 'status' => 'configured',
783 'message' => 'API rate limit exceeded. The account is valid but you\'ve reached the quota limit.'
784 ];
785 }
786
787 if ($response_code !== 200) {
788 return [
789 'status' => 'error',
790 'message' => 'API request failed with status code: ' . $response_code
791 ];
792 }
793
794 // Validate response body
795 $data = json_decode($response_body, true);
796
797 if (json_last_error() !== JSON_ERROR_NONE) {
798 return [
799 'status' => 'error',
800 'message' => 'Invalid API response format'
801 ];
802 }
803
804 // Check if response has expected structure
805 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
806 return [
807 'status' => 'error',
808 'message' => 'Unexpected API response structure'
809 ];
810 }
811
812 // Success - OAuth token is valid and working with PageSpeed API
813 return [
814 'status' => 'configured',
815 'message' => 'Google PageSpeed Insights connected successfully via Google OAuth'
816 ];
817 }
818
819 /**
820 * Disconnect Google Account
821 *
822 * @since 1.0.0
823 * @param WP_REST_Request $request Request object
824 * @return WP_REST_Response|WP_Error Response object
825 */
826 public function disconnect_google_account(WP_REST_Request $request) {
827 try {
828 // Best-effort revoke at Google so the refresh token (which never
829 // auto-expires) can't keep querying on the admin's behalf after
830 // disconnect. Failure here must not block local cleanup.
831 $token_to_revoke = $this->settings->get('google_refresh_token', '')
832 ?: $this->settings->get('google_access_token', '');
833 if (!empty($token_to_revoke)) {
834 wp_remote_post('https://oauth2.googleapis.com/revoke', [
835 'timeout' => 10,
836 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
837 'body' => ['token' => $token_to_revoke],
838 ]);
839 }
840
841 // Clear all Google-related settings
842 $this->settings->set('google_access_token', '');
843 $this->settings->set('google_refresh_token', '');
844 $this->settings->set('google_token_expires_in', '');
845 $this->settings->set('google_token_created', '');
846 $this->settings->set('google_account_connected', false);
847 // Also clear site selection. This targeted `google_search_console_site`,
848 // which is not a declared setting — Settings::set() rejects unknown
849 // keys, so the line never cleared anything and the selection
850 // survived every disconnect. The property picker writes
851 // `search_console_property`; clear that and the GA4 property beside
852 // it, so a reconnect under a different Google account doesn't
853 // inherit the previous account's selections.
854 $this->settings->set('search_console_property', '');
855 $this->settings->set('seo_analytics_google_analytics_property_id', '');
856
857 // The cached property list belongs to the account we just dropped —
858 // leaving it would serve those properties to whoever connects next.
859 self::purge_search_console_sites_cache();
860
861 // A deliberate disconnect is not a forced re-authorization.
862 delete_option('thinkrank_google_reconnect_required');
863
864 return new WP_REST_Response([
865 'success' => true,
866 'message' => 'Google account disconnected successfully'
867 ], 200);
868 } catch (\Exception $e) {
869 return new WP_Error(
870 'disconnect_failed',
871 'Failed to disconnect Google account: ' . $e->getMessage(),
872 ['status' => 500]
873 );
874 }
875 }
876
877
878 /**
879 * Get settings arguments for REST API
880 *
881 * @since 1.0.0
882 * @return array Settings arguments
883 */
884 private function get_settings_args(): array {
885 return [
886 'settings' => [
887 'required' => true,
888 'type' => 'object',
889 'description' => 'Integrations settings object'
890 ]
891 ];
892 }
893
894 /**
895 * Check read permissions
896 *
897 * @since 1.0.0
898 * @return bool Permission status
899 */
900 public function check_read_permissions(): bool {
901 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
902 }
903
904 /**
905 * Check manage permissions
906 *
907 * @since 1.0.0
908 * @return bool Permission status
909 */
910 public function check_manage_permissions(): bool {
911 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
912 }
913
914 /**
915 * Check permissions for credential-managing operations.
916 *
917 * Writing provider API keys, disconnecting Google (a server-side token
918 * revoke) and running live connection tests manage the site's third-party
919 * credentials, so they require an administrator — `thinkrank_settings` is
920 * delegatable to non-admin roles through the Role Manager. Mirrors the
921 * pattern used by the brand-visibility and AI-insights key writes.
922 *
923 * @since 1.29.0
924 * @return bool Permission status
925 */
926 public function check_credential_permissions(): bool {
927 return current_user_can('manage_options');
928 }
929
930 /**
931 * Verify GA4 tracking
932 * Following ThinkRank API response patterns
933 *
934 * @since 1.0.0
935 * @param WP_REST_Request $request Request object
936 * @return WP_REST_Response|WP_Error Response object
937 */
938 public function verify_ga4_tracking(WP_REST_Request $request) {
939 try {
940 // Empty is allowed and meaningful: verify_tracking() then reads the
941 // homepage and discovers whichever GA4 ID is actually serving. The
942 // old 400 made verification impossible on OAuth-connected sites
943 // that never typed an ID in — precisely the reported case (#250).
944 $measurement_id = (string) ( $request->get_param('measurement_id') ?? '' );
945
946 // Load tracking manager
947 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
948 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
949 }
950
951 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
952 $verification_result = $tracking_manager->verify_tracking($measurement_id);
953
954 return new WP_REST_Response([
955 'success' => true,
956 'data' => $verification_result,
957 'message' => 'Tracking verification completed'
958 ], 200);
959 } catch (\Exception $e) {
960 return new WP_Error(
961 'verification_failed',
962 'Tracking verification failed: ' . $e->getMessage(),
963 ['status' => 500]
964 );
965 }
966 }
967
968 /**
969 * Detect GA4 conflicts
970 * Following ThinkRank API response patterns
971 *
972 * @since 1.0.0
973 * @param WP_REST_Request $request Request object
974 * @return WP_REST_Response|WP_Error Response object
975 */
976 public function detect_ga4_conflicts(WP_REST_Request $request) {
977 try {
978 // Load tracking manager
979 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
980 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
981 }
982
983 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
984 $conflicts = $tracking_manager->detect_existing_tracking();
985
986 return new WP_REST_Response([
987 'success' => true,
988 'data' => [
989 'conflicts' => $conflicts,
990 'has_conflicts' => !empty($conflicts)
991 ],
992 'message' => 'Conflict detection completed'
993 ], 200);
994 } catch (\Exception $e) {
995 return new WP_Error(
996 'conflict_detection_failed',
997 'Conflict detection failed: ' . $e->getMessage(),
998 ['status' => 500]
999 );
1000 }
1001 }
1002 /**
1003 * Fingerprint the currently connected Google account.
1004 *
1005 * Prefers the refresh token: it is issued once per authorization grant and
1006 * survives every access-token rotation, so the cache stays warm for a whole
1007 * connection but changes the moment a different account authorizes. Falls
1008 * back to the access token when no refresh token was granted, which merely
1009 * shortens the effective cache life to one token lifetime.
1010 *
1011 * @since 1.28.0
1012 * @return string Non-reversible fingerprint, empty string when disconnected.
1013 */
1014 private function get_google_account_fingerprint(): string {
1015 $token = $this->settings->get('google_refresh_token', '')
1016 ?: $this->settings->get('google_access_token', '');
1017
1018 return empty($token) ? '' : md5((string) $token);
1019 }
1020
1021 /**
1022 * Drop the cached Search Console property list.
1023 *
1024 * Public and static so the OAuth paths — which run outside this controller
1025 * and after the credentials are gone — can invalidate the list on connect,
1026 * disconnect and revoke.
1027 *
1028 * @since 1.28.0
1029 * @return void
1030 */
1031 public static function purge_search_console_sites_cache(): void {
1032 delete_transient(self::SITES_CACHE_KEY);
1033 }
1034
1035 /**
1036 * Get Search Console Sites
1037 *
1038 * @since 1.0.0
1039 * @param WP_REST_Request $request Request object
1040 * @return WP_REST_Response Response object
1041 *
1042 * @throws \Exception On failure.
1043 */
1044 public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
1045 try {
1046 // Ensure Analytics_Manager is loaded for proactive token refresh
1047 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
1048 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
1049 }
1050
1051 // Proactively refresh token if expired — prevents 401 errors on initial load
1052 \ThinkRank\SEO\Analytics_Manager::ensure_fresh_token();
1053
1054 // Get access token (now guaranteed fresh if refresh_token is available)
1055 $access_token = $this->settings->get('google_access_token');
1056 $api_key = $this->settings->get('google_search_console_api_key');
1057
1058 if (empty($access_token)) {
1059 return new WP_REST_Response([
1060 'success' => false,
1061 'message' => 'Google account not connected'
1062 ], 401);
1063 }
1064
1065 // The verified-sites list changes rarely but costs a live Google
1066 // round-trip — serve from a 30-minute transient so the Google
1067 // Services screen doesn't hit Google on every render. The cache is
1068 // only honoured for the account that wrote it: reconnecting as a
1069 // different Google account must never hand back the previous
1070 // account's properties, which reads as "my site is missing".
1071 $account = $this->get_google_account_fingerprint();
1072 $cached_sites = get_transient(self::SITES_CACHE_KEY);
1073
1074 if (
1075 !$request->get_param('refresh')
1076 && is_array($cached_sites)
1077 && isset($cached_sites['account'], $cached_sites['payload'])
1078 && hash_equals($account, (string) $cached_sites['account'])
1079 ) {
1080 return new WP_REST_Response($cached_sites['payload'], 200);
1081 }
1082
1083 // Initialize Search Console Client
1084 if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) {
1085 require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php';
1086 }
1087
1088 // Ensure Analytics_Manager is loaded for token refresh
1089 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
1090 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
1091 }
1092
1093 // We need a client that can use the access token
1094 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1095 $api_key ?: '',
1096 30,
1097 $access_token
1098 );
1099
1100 $max_retries = 1;
1101 $retry_count = 0;
1102 $sites_data = [];
1103
1104 while ($retry_count <= $max_retries) {
1105 try {
1106 // Fetch sites
1107 $sites_data = $client->list_sites();
1108 break; // Success
1109 } catch (\Exception $e) {
1110 // Check for 401 error
1111 if ($e->getCode() === 401 && $retry_count < $max_retries) {
1112 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1113 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1114 error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
1115 }
1116
1117 // Initialize Analytics Manager to handle token refresh
1118 $analytics_manager = new \ThinkRank\SEO\Analytics_Manager();
1119 $analytics_manager->refresh_access_token(true); // Force refresh
1120
1121 // Get new token
1122 $new_access_token = $this->settings->get('google_access_token');
1123 // Update client with new token
1124 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1125 $api_key ?: '',
1126 30,
1127 $new_access_token
1128 );
1129
1130 $retry_count++;
1131 continue;
1132 }
1133
1134 throw $e;
1135 }
1136 }
1137
1138 $sites = [];
1139 if (isset($sites_data['siteEntry']) && is_array($sites_data['siteEntry'])) {
1140 foreach ($sites_data['siteEntry'] as $site) {
1141 $sites[] = [
1142 'siteUrl' => $site['siteUrl'] ?? '',
1143 'permissionLevel' => $site['permissionLevel'] ?? 'siteOwner'
1144 ];
1145 }
1146 }
1147
1148 $payload = [
1149 'success' => true,
1150 'data' => [
1151 'sites' => $sites
1152 ],
1153 'message' => 'Search Console sites retrieved successfully'
1154 ];
1155
1156 // Cache successes only — errors must stay retryable. Recompute the
1157 // fingerprint: the 401 retry above may have rotated the access
1158 // token, and the cache must be stamped with the account it came
1159 // from, not the one we started the request with.
1160 if (!empty($sites)) {
1161 set_transient(
1162 self::SITES_CACHE_KEY,
1163 [
1164 'account' => $this->get_google_account_fingerprint(),
1165 'payload' => $payload,
1166 ],
1167 30 * MINUTE_IN_SECONDS
1168 );
1169 }
1170
1171 return new WP_REST_Response($payload, 200);
1172 } catch (\Exception $e) {
1173 $code = $e->getCode();
1174 $status = ($code >= 400 && $code < 600) ? $code : 500;
1175
1176 return new WP_REST_Response([
1177 'success' => false,
1178 'message' => 'Failed to retrieve Search Console sites: ' . $e->getMessage()
1179 ], $status);
1180 }
1181 }
1182 }
1183