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

1,145 lines 42.5 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_manage_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_manage_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_manage_permissions'] // Changed to check_manage_permissions for consistency
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 // Sanitize numeric settings
408 $sanitized['api_timeout'] = absint($settings['api_timeout'] ?? 30);
409 $sanitized['cache_duration'] = absint($settings['cache_duration'] ?? 3600);
410
411 // Sanitize GA4 tracking settings
412 $sanitized['ga4_measurement_id'] = sanitize_text_field($settings['ga4_measurement_id'] ?? '');
413 $sanitized['ga4_auto_inject'] = isset($settings['ga4_auto_inject']) ? (bool) $settings['ga4_auto_inject'] : false;
414 $sanitized['ga4_anonymize_ip'] = isset($settings['ga4_anonymize_ip']) ? (bool) $settings['ga4_anonymize_ip'] : false;
415 $sanitized['ga4_exclude_admin'] = isset($settings['ga4_exclude_admin']) ? (bool) $settings['ga4_exclude_admin'] : false;
416 $sanitized['ga4_tracking_verified'] = isset($settings['ga4_tracking_verified']) ? (bool) $settings['ga4_tracking_verified'] : false;
417 $sanitized['ga4_last_verification'] = sanitize_text_field($settings['ga4_last_verification'] ?? '');
418
419 // Sanitize boolean settings
420 $sanitized['enable_rate_limiting'] = isset($settings['enable_rate_limiting']) ? (bool) $settings['enable_rate_limiting'] : true;
421 $sanitized['auto_test_connections'] = isset($settings['auto_test_connections']) ? (bool) $settings['auto_test_connections'] : true;
422 $sanitized['retry_failed_requests'] = isset($settings['retry_failed_requests']) ? (bool) $settings['retry_failed_requests'] : true;
423
424 return $sanitized;
425 }
426
427 /**
428 * Mask API key for security display (XXX pattern)
429 *
430 * @since 1.0.0
431 * @param string $api_key API key to mask
432 * @return string Masked API key or empty string
433 */
434 private function mask_api_key(string $api_key): string {
435 if (empty($api_key)) {
436 return '';
437 }
438
439 // Show first 6 characters + XXXX suffix (consistent with placeholders)
440 if (strlen($api_key) > 10) {
441 return substr($api_key, 0, 6) . 'XXXX';
442 }
443
444 return 'XXXX';
445 }
446
447 /**
448 * Whether a submitted value is the masked sentinel produced by mask_api_key
449 * (so we don't persist the mask over a real key).
450 *
451 * @since 1.0.0
452 * @param string $value Submitted value
453 * @return bool
454 */
455 private function is_masked_api_key(string $value): bool {
456 return 'XXXX' === $value || str_ends_with($value, 'XXXX');
457 }
458
459 /**
460 * Test Google Analytics API connection
461 *
462 * Makes a real API call to Google's PageSpeed Insights API to verify
463 * that the API key is valid and has proper permissions.
464 *
465 * @since 1.0.0
466 * @param string $api_key API key to test
467 * @return array Test result with status and message
468 */
469 private function test_google_analytics(string $api_key): array {
470 // Basic format validation
471 if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
472 return [
473 'status' => 'error',
474 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
475 ];
476 }
477
478 // Make a real API call to test the key
479 // Using PageSpeed Insights API as it uses the same API key and has a simple test endpoint
480 $test_url = add_query_arg([
481 'url' => 'https://example.com/',
482 'key' => $api_key,
483 'category' => 'performance',
484 'strategy' => 'mobile'
485 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
486
487 // Get timeout setting from settings or use default
488 $timeout = absint($this->settings->get('api_timeout') ?? 30);
489
490 $response = wp_remote_get($test_url, [
491 'timeout' => $timeout,
492 'headers' => [
493 'Accept' => 'application/json'
494 ],
495 'sslverify' => true
496 ]);
497
498 // Handle network/connection errors
499 if (is_wp_error($response)) {
500 return [
501 'status' => 'error',
502 'message' => 'Connection failed: ' . $response->get_error_message()
503 ];
504 }
505
506 $response_code = wp_remote_retrieve_response_code($response);
507 $response_body = wp_remote_retrieve_body($response);
508
509 // Handle HTTP errors
510 if ($response_code === 400) {
511 $error_data = json_decode($response_body, true);
512 $error_message = $error_data['error']['message'] ?? 'Bad request';
513
514 return [
515 'status' => 'error',
516 'message' => 'API key validation failed: ' . $error_message
517 ];
518 }
519
520 if ($response_code === 403) {
521 $error_data = json_decode($response_body, true);
522 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
523
524 // Check if it's an API key issue
525 if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
526 return [
527 'status' => 'error',
528 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
529 ];
530 }
531
532 return [
533 'status' => 'error',
534 'message' => 'Access denied: ' . $error_message
535 ];
536 }
537
538 if ($response_code === 429) {
539 return [
540 'status' => 'configured',
541 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
542 ];
543 }
544
545 if ($response_code !== 200) {
546 return [
547 'status' => 'error',
548 'message' => 'API request failed with status code: ' . $response_code
549 ];
550 }
551
552 // Validate response body
553 $data = json_decode($response_body, true);
554
555 if (json_last_error() !== JSON_ERROR_NONE) {
556 return [
557 'status' => 'error',
558 'message' => 'Invalid API response format'
559 ];
560 }
561
562 // Check if response has expected structure
563 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
564 return [
565 'status' => 'error',
566 'message' => 'Unexpected API response structure'
567 ];
568 }
569
570 // Success - API key is valid and working
571 return [
572 'status' => 'configured',
573 'message' => 'Google API key is valid and working correctly'
574 ];
575 }
576
577 /**
578 * Test Google Search Console API connection
579 *
580 * Makes a real API call to Google's PageSpeed Insights API to verify
581 * that the API key is valid and has proper permissions.
582 *
583 * @since 1.0.0
584 * @param string $api_key API key to test
585 * @return array Test result with status and message
586 */
587 private function test_search_console(string $api_key): array {
588 // Basic format validation
589 if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
590 return [
591 'status' => 'error',
592 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
593 ];
594 }
595
596 // Make a real API call to test the key
597 // Using PageSpeed Insights API as it uses the same API key format
598 $test_url = add_query_arg([
599 'url' => 'https://example.com/',
600 'key' => $api_key,
601 'category' => 'seo',
602 'strategy' => 'desktop'
603 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
604
605 // Get timeout setting from settings or use default
606 $timeout = absint($this->settings->get('api_timeout') ?? 30);
607
608 $response = wp_remote_get($test_url, [
609 'timeout' => $timeout,
610 'headers' => [
611 'Accept' => 'application/json'
612 ],
613 'sslverify' => true
614 ]);
615
616 // Handle network/connection errors
617 if (is_wp_error($response)) {
618 return [
619 'status' => 'error',
620 'message' => 'Connection failed: ' . $response->get_error_message()
621 ];
622 }
623
624 $response_code = wp_remote_retrieve_response_code($response);
625 $response_body = wp_remote_retrieve_body($response);
626
627 // Handle HTTP errors
628 if ($response_code === 400) {
629 $error_data = json_decode($response_body, true);
630 $error_message = $error_data['error']['message'] ?? 'Bad request';
631
632 return [
633 'status' => 'error',
634 'message' => 'API key validation failed: ' . $error_message
635 ];
636 }
637
638 if ($response_code === 403) {
639 $error_data = json_decode($response_body, true);
640 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
641
642 // Check if it's an API key issue
643 if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
644 return [
645 'status' => 'error',
646 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
647 ];
648 }
649
650 return [
651 'status' => 'error',
652 'message' => 'Access denied: ' . $error_message
653 ];
654 }
655
656 if ($response_code === 429) {
657 return [
658 'status' => 'configured',
659 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
660 ];
661 }
662
663 if ($response_code !== 200) {
664 return [
665 'status' => 'error',
666 'message' => 'API request failed with status code: ' . $response_code
667 ];
668 }
669
670 // Validate response body
671 $data = json_decode($response_body, true);
672
673 if (json_last_error() !== JSON_ERROR_NONE) {
674 return [
675 'status' => 'error',
676 'message' => 'Invalid API response format'
677 ];
678 }
679
680 // Check if response has expected structure
681 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
682 return [
683 'status' => 'error',
684 'message' => 'Unexpected API response structure'
685 ];
686 }
687
688 // Success - API key is valid and working
689 return [
690 'status' => 'configured',
691 'message' => 'Google API key is valid and working correctly'
692 ];
693 }
694
695 /**
696 * Test Google PageSpeed API connection using OAuth token
697 *
698 * Makes a real API call to Google's PageSpeed Insights API using OAuth
699 * Bearer token to verify that the account is properly connected.
700 *
701 * @since 1.0.0
702 * @param string $access_token OAuth access token
703 * @return array Test result with status and message
704 */
705 private function test_pagespeed_oauth(string $access_token): array {
706 if (empty($access_token)) {
707 return [
708 'status' => 'error',
709 'message' => 'Google account not connected. Please connect your Google account to use PageSpeed Insights.'
710 ];
711 }
712
713 // Make a real API call to test the OAuth token with PageSpeed API
714 $test_url = add_query_arg([
715 'url' => 'https://example.com/',
716 'category' => 'performance',
717 'strategy' => 'mobile'
718 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
719
720 // Get timeout setting from settings or use default
721 $timeout = absint($this->settings->get('api_timeout') ?? 30);
722
723 $response = wp_remote_get($test_url, [
724 'timeout' => $timeout,
725 'headers' => [
726 'Accept' => 'application/json',
727 'Authorization' => 'Bearer ' . $access_token
728 ],
729 'sslverify' => true
730 ]);
731
732 // Handle network/connection errors
733 if (is_wp_error($response)) {
734 return [
735 'status' => 'error',
736 'message' => 'Connection failed: ' . $response->get_error_message()
737 ];
738 }
739
740 $response_code = wp_remote_retrieve_response_code($response);
741 $response_body = wp_remote_retrieve_body($response);
742
743 // Handle HTTP errors
744 if ($response_code === 401) {
745 return [
746 'status' => 'error',
747 'message' => 'Google account token is expired or invalid. Please reconnect your Google account.'
748 ];
749 }
750
751 if ($response_code === 403) {
752 $error_data = json_decode($response_body, true);
753 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
754 return [
755 'status' => 'error',
756 'message' => 'Access denied: ' . $error_message
757 ];
758 }
759
760 if ($response_code === 429) {
761 return [
762 'status' => 'configured',
763 'message' => 'API rate limit exceeded. The account is valid but you\'ve reached the quota limit.'
764 ];
765 }
766
767 if ($response_code !== 200) {
768 return [
769 'status' => 'error',
770 'message' => 'API request failed with status code: ' . $response_code
771 ];
772 }
773
774 // Validate response body
775 $data = json_decode($response_body, true);
776
777 if (json_last_error() !== JSON_ERROR_NONE) {
778 return [
779 'status' => 'error',
780 'message' => 'Invalid API response format'
781 ];
782 }
783
784 // Check if response has expected structure
785 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
786 return [
787 'status' => 'error',
788 'message' => 'Unexpected API response structure'
789 ];
790 }
791
792 // Success - OAuth token is valid and working with PageSpeed API
793 return [
794 'status' => 'configured',
795 'message' => 'Google PageSpeed Insights connected successfully via Google OAuth'
796 ];
797 }
798
799 /**
800 * Disconnect Google Account
801 *
802 * @since 1.0.0
803 * @param WP_REST_Request $request Request object
804 * @return WP_REST_Response|WP_Error Response object
805 */
806 public function disconnect_google_account(WP_REST_Request $request): WP_REST_Response|WP_Error {
807 try {
808 // Best-effort revoke at Google so the refresh token (which never
809 // auto-expires) can't keep querying on the admin's behalf after
810 // disconnect. Failure here must not block local cleanup.
811 $token_to_revoke = $this->settings->get('google_refresh_token', '')
812 ?: $this->settings->get('google_access_token', '');
813 if (!empty($token_to_revoke)) {
814 wp_remote_post('https://oauth2.googleapis.com/revoke', [
815 'timeout' => 10,
816 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
817 'body' => ['token' => $token_to_revoke],
818 ]);
819 }
820
821 // Clear all Google-related settings
822 $this->settings->set('google_access_token', '');
823 $this->settings->set('google_refresh_token', '');
824 $this->settings->set('google_token_expires_in', '');
825 $this->settings->set('google_token_created', '');
826 $this->settings->set('google_account_connected', false);
827 // Also clear site selection. This targeted `google_search_console_site`,
828 // which is not a declared setting — Settings::set() rejects unknown
829 // keys, so the line never cleared anything and the selection
830 // survived every disconnect. The property picker writes
831 // `search_console_property`; clear that and the GA4 property beside
832 // it, so a reconnect under a different Google account doesn't
833 // inherit the previous account's selections.
834 $this->settings->set('search_console_property', '');
835 $this->settings->set('seo_analytics_google_analytics_property_id', '');
836
837 // The cached property list belongs to the account we just dropped —
838 // leaving it would serve those properties to whoever connects next.
839 self::purge_search_console_sites_cache();
840
841 // A deliberate disconnect is not a forced re-authorization.
842 delete_option('thinkrank_google_reconnect_required');
843
844 return new WP_REST_Response([
845 'success' => true,
846 'message' => 'Google account disconnected successfully'
847 ], 200);
848 } catch (\Exception $e) {
849 return new WP_Error(
850 'disconnect_failed',
851 'Failed to disconnect Google account: ' . $e->getMessage(),
852 ['status' => 500]
853 );
854 }
855 }
856
857
858 /**
859 * Get settings arguments for REST API
860 *
861 * @since 1.0.0
862 * @return array Settings arguments
863 */
864 private function get_settings_args(): array {
865 return [
866 'settings' => [
867 'required' => true,
868 'type' => 'object',
869 'description' => 'Integrations settings object'
870 ]
871 ];
872 }
873
874 /**
875 * Check read permissions
876 *
877 * @since 1.0.0
878 * @return bool Permission status
879 */
880 public function check_read_permissions(): bool {
881 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
882 }
883
884 /**
885 * Check manage permissions
886 *
887 * @since 1.0.0
888 * @return bool Permission status
889 */
890 public function check_manage_permissions(): bool {
891 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
892 }
893
894 /**
895 * Verify GA4 tracking
896 * Following ThinkRank API response patterns
897 *
898 * @since 1.0.0
899 * @param WP_REST_Request $request Request object
900 * @return WP_REST_Response|WP_Error Response object
901 */
902 public function verify_ga4_tracking(WP_REST_Request $request): WP_REST_Response|WP_Error {
903 try {
904 // Empty is allowed and meaningful: verify_tracking() then reads the
905 // homepage and discovers whichever GA4 ID is actually serving. The
906 // old 400 made verification impossible on OAuth-connected sites
907 // that never typed an ID in — precisely the reported case (#250).
908 $measurement_id = (string) ( $request->get_param('measurement_id') ?? '' );
909
910 // Load tracking manager
911 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
912 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
913 }
914
915 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
916 $verification_result = $tracking_manager->verify_tracking($measurement_id);
917
918 return new WP_REST_Response([
919 'success' => true,
920 'data' => $verification_result,
921 'message' => 'Tracking verification completed'
922 ], 200);
923 } catch (\Exception $e) {
924 return new WP_Error(
925 'verification_failed',
926 'Tracking verification failed: ' . $e->getMessage(),
927 ['status' => 500]
928 );
929 }
930 }
931
932 /**
933 * Detect GA4 conflicts
934 * Following ThinkRank API response patterns
935 *
936 * @since 1.0.0
937 * @param WP_REST_Request $request Request object
938 * @return WP_REST_Response|WP_Error Response object
939 */
940 public function detect_ga4_conflicts(WP_REST_Request $request): WP_REST_Response|WP_Error {
941 try {
942 // Load tracking manager
943 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
944 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
945 }
946
947 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
948 $conflicts = $tracking_manager->detect_existing_tracking();
949
950 return new WP_REST_Response([
951 'success' => true,
952 'data' => [
953 'conflicts' => $conflicts,
954 'has_conflicts' => !empty($conflicts)
955 ],
956 'message' => 'Conflict detection completed'
957 ], 200);
958 } catch (\Exception $e) {
959 return new WP_Error(
960 'conflict_detection_failed',
961 'Conflict detection failed: ' . $e->getMessage(),
962 ['status' => 500]
963 );
964 }
965 }
966 /**
967 * Fingerprint the currently connected Google account.
968 *
969 * Prefers the refresh token: it is issued once per authorization grant and
970 * survives every access-token rotation, so the cache stays warm for a whole
971 * connection but changes the moment a different account authorizes. Falls
972 * back to the access token when no refresh token was granted, which merely
973 * shortens the effective cache life to one token lifetime.
974 *
975 * @since 1.28.0
976 * @return string Non-reversible fingerprint, empty string when disconnected.
977 */
978 private function get_google_account_fingerprint(): string {
979 $token = $this->settings->get('google_refresh_token', '')
980 ?: $this->settings->get('google_access_token', '');
981
982 return empty($token) ? '' : md5((string) $token);
983 }
984
985 /**
986 * Drop the cached Search Console property list.
987 *
988 * Public and static so the OAuth paths — which run outside this controller
989 * and after the credentials are gone — can invalidate the list on connect,
990 * disconnect and revoke.
991 *
992 * @since 1.28.0
993 * @return void
994 */
995 public static function purge_search_console_sites_cache(): void {
996 delete_transient(self::SITES_CACHE_KEY);
997 }
998
999 /**
1000 * Get Search Console Sites
1001 *
1002 * @since 1.0.0
1003 * @param WP_REST_Request $request Request object
1004 * @return WP_REST_Response Response object
1005 */
1006 public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
1007 try {
1008 // Ensure Analytics_Manager is loaded for proactive token refresh
1009 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
1010 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
1011 }
1012
1013 // Proactively refresh token if expired — prevents 401 errors on initial load
1014 \ThinkRank\SEO\Analytics_Manager::ensure_fresh_token();
1015
1016 // Get access token (now guaranteed fresh if refresh_token is available)
1017 $access_token = $this->settings->get('google_access_token');
1018 $api_key = $this->settings->get('google_search_console_api_key');
1019
1020 if (empty($access_token)) {
1021 return new WP_REST_Response([
1022 'success' => false,
1023 'message' => 'Google account not connected'
1024 ], 401);
1025 }
1026
1027 // The verified-sites list changes rarely but costs a live Google
1028 // round-trip — serve from a 30-minute transient so the Google
1029 // Services screen doesn't hit Google on every render. The cache is
1030 // only honoured for the account that wrote it: reconnecting as a
1031 // different Google account must never hand back the previous
1032 // account's properties, which reads as "my site is missing".
1033 $account = $this->get_google_account_fingerprint();
1034 $cached_sites = get_transient(self::SITES_CACHE_KEY);
1035
1036 if (
1037 !$request->get_param('refresh')
1038 && is_array($cached_sites)
1039 && isset($cached_sites['account'], $cached_sites['payload'])
1040 && hash_equals($account, (string) $cached_sites['account'])
1041 ) {
1042 return new WP_REST_Response($cached_sites['payload'], 200);
1043 }
1044
1045 // Initialize Search Console Client
1046 if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) {
1047 require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php';
1048 }
1049
1050 // Ensure Analytics_Manager is loaded for token refresh
1051 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
1052 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
1053 }
1054
1055 // We need a client that can use the access token
1056 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1057 $api_key ?: '',
1058 30,
1059 $access_token
1060 );
1061
1062 $max_retries = 1;
1063 $retry_count = 0;
1064 $sites_data = [];
1065
1066 while ($retry_count <= $max_retries) {
1067 try {
1068 // Fetch sites
1069 $sites_data = $client->list_sites();
1070 break; // Success
1071 } catch (\Exception $e) {
1072 // Check for 401 error
1073 if ($e->getCode() === 401 && $retry_count < $max_retries) {
1074 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1075 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1076 error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
1077 }
1078
1079 // Initialize Analytics Manager to handle token refresh
1080 $analytics_manager = new \ThinkRank\SEO\Analytics_Manager();
1081 $analytics_manager->refresh_access_token(true); // Force refresh
1082
1083 // Get new token
1084 $new_access_token = $this->settings->get('google_access_token');
1085 // Update client with new token
1086 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1087 $api_key ?: '',
1088 30,
1089 $new_access_token
1090 );
1091
1092 $retry_count++;
1093 continue;
1094 }
1095
1096 throw $e;
1097 }
1098 }
1099
1100 $sites = [];
1101 if (isset($sites_data['siteEntry']) && is_array($sites_data['siteEntry'])) {
1102 foreach ($sites_data['siteEntry'] as $site) {
1103 $sites[] = [
1104 'siteUrl' => $site['siteUrl'] ?? '',
1105 'permissionLevel' => $site['permissionLevel'] ?? 'siteOwner'
1106 ];
1107 }
1108 }
1109
1110 $payload = [
1111 'success' => true,
1112 'data' => [
1113 'sites' => $sites
1114 ],
1115 'message' => 'Search Console sites retrieved successfully'
1116 ];
1117
1118 // Cache successes only — errors must stay retryable. Recompute the
1119 // fingerprint: the 401 retry above may have rotated the access
1120 // token, and the cache must be stamped with the account it came
1121 // from, not the one we started the request with.
1122 if (!empty($sites)) {
1123 set_transient(
1124 self::SITES_CACHE_KEY,
1125 [
1126 'account' => $this->get_google_account_fingerprint(),
1127 'payload' => $payload,
1128 ],
1129 30 * MINUTE_IN_SECONDS
1130 );
1131 }
1132
1133 return new WP_REST_Response($payload, 200);
1134 } catch (\Exception $e) {
1135 $code = $e->getCode();
1136 $status = ($code >= 400 && $code < 600) ? $code : 500;
1137
1138 return new WP_REST_Response([
1139 'success' => false,
1140 'message' => 'Failed to retrieve Search Console sites: ' . $e->getMessage()
1141 ], $status);
1142 }
1143 }
1144 }
1145