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

1,055 lines 37.8 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 * Settings instance
57 *
58 * @since 1.0.0
59 * @var Settings
60 */
61 private Settings $settings;
62
63 /**
64 * Constructor
65 *
66 * @since 1.0.0
67 */
68 public function __construct() {
69 $this->settings = Settings::instance();
70 }
71
72 /**
73 * Register API routes
74 *
75 * @since 1.0.0
76 */
77 public function register_routes(): void {
78 // Integrations settings management
79 register_rest_route(
80 $this->namespace,
81 '/' . $this->rest_base . '/settings',
82 [
83 [
84 'methods' => 'GET',
85 'callback' => [$this, 'get_settings'],
86 'permission_callback' => [$this, 'check_read_permissions']
87 ],
88 [
89 'methods' => 'POST',
90 'callback' => [$this, 'update_settings'],
91 'permission_callback' => [$this, 'check_manage_permissions'],
92 'args' => $this->get_settings_args()
93 ]
94 ]
95 );
96
97 // Test Google API connections
98 register_rest_route(
99 $this->namespace,
100 '/' . $this->rest_base . '/test-connections',
101 [
102 [
103 'methods' => 'POST',
104 'callback' => [$this, 'test_connections'],
105 'permission_callback' => [$this, 'check_manage_permissions']
106 ]
107 ]
108 );
109
110 // Verify GA4 tracking
111 register_rest_route(
112 $this->namespace,
113 '/' . $this->rest_base . '/verify-ga4-tracking',
114 [
115 [
116 'methods' => 'POST',
117 'callback' => [$this, 'verify_ga4_tracking'],
118 'permission_callback' => [$this, 'check_manage_permissions'],
119 'args' => [
120 'measurement_id' => [
121 'required' => true,
122 'type' => 'string',
123 // No regex delimiters — WP's REST validator wraps the
124 // pattern in its own (#...#u), so a leading/trailing
125 // slash would require literal slashes in the value.
126 'pattern' => '^G-[A-Z0-9]{10}$',
127 'sanitize_callback' => 'sanitize_text_field',
128 'description' => 'GA4 Measurement ID in format G-XXXXXXXXXX'
129 ]
130 ]
131 ]
132 ]
133 );
134
135 // Detect GA4 conflicts
136 register_rest_route(
137 $this->namespace,
138 '/' . $this->rest_base . '/detect-ga4-conflicts',
139 [
140 [
141 'methods' => 'GET',
142 'callback' => [$this, 'detect_ga4_conflicts'],
143 'permission_callback' => [$this, 'check_read_permissions']
144 ]
145 ]
146 );
147
148 // Get Search Console Sites
149 register_rest_route(
150 $this->namespace,
151 '/' . $this->rest_base . '/search-console/sites',
152 [
153 [
154 'methods' => 'GET',
155 'callback' => [$this, 'get_search_console_sites'],
156 'permission_callback' => [$this, 'check_manage_permissions']
157 ]
158 ]
159 );
160
161 // Disconnect Google Account
162 register_rest_route($this->namespace, '/integrations/google/disconnect', [
163 'methods' => WP_REST_Server::CREATABLE,
164 'callback' => [$this, 'disconnect_google_account'],
165 'permission_callback' => [$this, 'check_manage_permissions'] // Changed to check_manage_permissions for consistency
166 ]);
167
168 // Note: there is no save-google-token route. Tokens are swapped
169 // server-to-server in Google_OAuth_Proxy and never pass through the
170 // browser, so there is nothing for the SPA to hand back.
171 }
172
173 /**
174 * Get integrations settings
175 *
176 * @since 1.0.0
177 *
178 * @param WP_REST_Request $request Request object
179 * @return WP_REST_Response Response object
180 */
181 public function get_settings(WP_REST_Request $request): WP_REST_Response {
182 try {
183 $settings = $this->get_integrations_settings();
184
185 return new WP_REST_Response([
186 'success' => true,
187 'data' => [
188 'settings' => $settings
189 ],
190 'message' => 'Integrations settings retrieved successfully'
191 ], 200);
192 } catch (\Exception $e) {
193 return new WP_REST_Response([
194 'success' => false,
195 'message' => 'Failed to retrieve integrations settings: ' . $e->getMessage()
196 ], 500);
197 }
198 }
199
200 /**
201 * Update integrations settings
202 *
203 * @since 1.0.0
204 *
205 * @param WP_REST_Request $request Request object
206 * @return WP_REST_Response Response object
207 */
208 public function update_settings(WP_REST_Request $request): WP_REST_Response {
209 try {
210 $settings = $request->get_param('settings');
211
212 if (empty($settings) || !is_array($settings)) {
213 return new WP_REST_Response([
214 'success' => false,
215 'message' => 'Invalid settings data provided'
216 ], 400);
217 }
218
219 // Sanitize and save settings
220 $sanitized_settings = $this->sanitize_settings($settings);
221 $success = $this->save_integrations_settings($sanitized_settings);
222
223 if ($success) {
224 return new WP_REST_Response([
225 'success' => true,
226 'data' => [
227 'settings' => $this->get_integrations_settings()
228 ],
229 'message' => 'Integrations settings saved successfully'
230 ], 200);
231 } else {
232 return new WP_REST_Response([
233 'success' => false,
234 'message' => 'Failed to save integrations settings'
235 ], 500);
236 }
237 } catch (\Exception $e) {
238 return new WP_REST_Response([
239 'success' => false,
240 'message' => 'Failed to update integrations settings: ' . $e->getMessage()
241 ], 500);
242 }
243 }
244
245 /**
246 * Test Google API connections
247 *
248 * @since 1.0.0
249 *
250 * @param WP_REST_Request $request Request object
251 * @return WP_REST_Response Response object
252 */
253 public function test_connections(WP_REST_Request $request): WP_REST_Response {
254 try {
255 // Get raw, unmasked API keys directly from Settings class for testing
256 $analytics_key = $this->settings->get('google_analytics_api_key');
257 $search_console_key = $this->settings->get('google_search_console_api_key');
258 $pagespeed_key = $this->settings->get('google_pagespeed_api_key');
259
260 $results = [];
261
262 // Test Google Analytics API
263 if (!empty($analytics_key)) {
264 $results['google_analytics'] = $this->test_google_analytics($analytics_key);
265 } else {
266 $results['google_analytics'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
267 }
268
269 // Test Search Console API
270 if (!empty($search_console_key)) {
271 $results['search_console'] = $this->test_search_console($search_console_key);
272 } else {
273 $results['search_console'] = ['status' => 'not_configured', 'message' => 'API key not configured'];
274 }
275
276 // Test PageSpeed API (now uses OAuth access token)
277 $access_token = $this->settings->get('google_access_token');
278 if (!empty($access_token)) {
279 $results['pagespeed'] = $this->test_pagespeed_oauth($access_token);
280 } else {
281 $results['pagespeed'] = ['status' => 'not_configured', 'message' => 'Google account not connected'];
282 }
283
284 return new WP_REST_Response([
285 'success' => true,
286 'data' => $results,
287 'message' => 'Connection tests completed'
288 ], 200);
289 } catch (\Exception $e) {
290 return new WP_REST_Response([
291 'success' => false,
292 'message' => 'Connection test failed: ' . $e->getMessage()
293 ], 500);
294 }
295 }
296
297 /**
298 * Get integrations settings from Settings class (with encryption)
299 *
300 * @since 1.0.0
301 * @return array Settings array
302 */
303 private function get_integrations_settings(): array {
304 $settings = [];
305
306 // Get encrypted API keys
307 $settings['google_analytics_api_key'] = $this->settings->get('google_analytics_api_key');
308 $settings['google_search_console_api_key'] = $this->settings->get('google_search_console_api_key');
309 $settings['google_pagespeed_api_key'] = $this->settings->get('google_pagespeed_api_key');
310
311 // Get GA4 tracking settings (let Settings class handle defaults)
312 $settings['ga4_measurement_id'] = $this->settings->get('ga4_measurement_id');
313 $settings['ga4_auto_inject'] = $this->settings->get('ga4_auto_inject');
314 $settings['ga4_anonymize_ip'] = $this->settings->get('ga4_anonymize_ip');
315 $settings['ga4_exclude_admin'] = $this->settings->get('ga4_exclude_admin');
316 $settings['ga4_tracking_verified'] = $this->settings->get('ga4_tracking_verified');
317 $settings['ga4_last_verification'] = $this->settings->get('ga4_last_verification');
318
319 // Get other integration settings (let Settings class handle defaults)
320 $settings['api_timeout'] = $this->settings->get('api_timeout');
321 $settings['enable_rate_limiting'] = $this->settings->get('enable_rate_limiting');
322 $settings['cache_duration'] = $this->settings->get('cache_duration');
323 $settings['auto_test_connections'] = $this->settings->get('auto_test_connections');
324 $settings['retry_failed_requests'] = $this->settings->get('retry_failed_requests');
325 $settings['google_account_connected'] = $this->settings->get('google_account_connected');
326
327 // Mask API keys for security (like OpenAI/Claude keys)
328 $settings['google_analytics_api_key'] = $this->mask_api_key($settings['google_analytics_api_key']);
329 $settings['google_search_console_api_key'] = $this->mask_api_key($settings['google_search_console_api_key']);
330 $settings['google_pagespeed_api_key'] = $this->mask_api_key($settings['google_pagespeed_api_key']);
331
332 return $settings;
333 }
334
335 /**
336 * Save integrations settings using Settings class (with encryption)
337 *
338 * @since 1.0.0
339 * @param array $settings Settings to save
340 * @return bool Success status
341 */
342 private function save_integrations_settings(array $settings): bool {
343 $success = true;
344
345 // Save each setting individually using the Settings class
346 // This ensures proper encryption for API keys
347 foreach ($settings as $key => $value) {
348 if (!$this->settings->set($key, $value)) {
349 $success = false;
350 // Setting save failed - error details available through settings manager
351 }
352 }
353
354 return $success;
355 }
356
357 /**
358 * Sanitize settings data
359 *
360 * @since 1.0.0
361 * @param array $settings Raw settings
362 * @return array Sanitized settings
363 */
364 private function sanitize_settings(array $settings): array {
365 $sanitized = [];
366
367 // Sanitize API keys. Skip empty values AND the masked sentinel returned
368 // by get_integrations_settings (mask_api_key appends 'XXXX'); resubmitting
369 // the mask must not overwrite the real stored key.
370 foreach (['google_analytics_api_key', 'google_search_console_api_key', 'google_pagespeed_api_key'] as $key_field) {
371 if (!empty($settings[$key_field]) && !$this->is_masked_api_key($settings[$key_field])) {
372 $sanitized[$key_field] = sanitize_text_field($settings[$key_field]);
373 }
374 }
375
376 // Sanitize numeric settings
377 $sanitized['api_timeout'] = absint($settings['api_timeout'] ?? 30);
378 $sanitized['cache_duration'] = absint($settings['cache_duration'] ?? 3600);
379
380 // Sanitize GA4 tracking settings
381 $sanitized['ga4_measurement_id'] = sanitize_text_field($settings['ga4_measurement_id'] ?? '');
382 $sanitized['ga4_auto_inject'] = isset($settings['ga4_auto_inject']) ? (bool) $settings['ga4_auto_inject'] : false;
383 $sanitized['ga4_anonymize_ip'] = isset($settings['ga4_anonymize_ip']) ? (bool) $settings['ga4_anonymize_ip'] : false;
384 $sanitized['ga4_exclude_admin'] = isset($settings['ga4_exclude_admin']) ? (bool) $settings['ga4_exclude_admin'] : false;
385 $sanitized['ga4_tracking_verified'] = isset($settings['ga4_tracking_verified']) ? (bool) $settings['ga4_tracking_verified'] : false;
386 $sanitized['ga4_last_verification'] = sanitize_text_field($settings['ga4_last_verification'] ?? '');
387
388 // Sanitize boolean settings
389 $sanitized['enable_rate_limiting'] = isset($settings['enable_rate_limiting']) ? (bool) $settings['enable_rate_limiting'] : true;
390 $sanitized['auto_test_connections'] = isset($settings['auto_test_connections']) ? (bool) $settings['auto_test_connections'] : true;
391 $sanitized['retry_failed_requests'] = isset($settings['retry_failed_requests']) ? (bool) $settings['retry_failed_requests'] : true;
392
393 return $sanitized;
394 }
395
396 /**
397 * Mask API key for security display (XXX pattern)
398 *
399 * @since 1.0.0
400 * @param string $api_key API key to mask
401 * @return string Masked API key or empty string
402 */
403 private function mask_api_key(string $api_key): string {
404 if (empty($api_key)) {
405 return '';
406 }
407
408 // Show first 6 characters + XXXX suffix (consistent with placeholders)
409 if (strlen($api_key) > 10) {
410 return substr($api_key, 0, 6) . 'XXXX';
411 }
412
413 return 'XXXX';
414 }
415
416 /**
417 * Whether a submitted value is the masked sentinel produced by mask_api_key
418 * (so we don't persist the mask over a real key).
419 *
420 * @since 1.0.0
421 * @param string $value Submitted value
422 * @return bool
423 */
424 private function is_masked_api_key(string $value): bool {
425 return 'XXXX' === $value || str_ends_with($value, 'XXXX');
426 }
427
428 /**
429 * Test Google Analytics API connection
430 *
431 * Makes a real API call to Google's PageSpeed Insights API to verify
432 * that the API key is valid and has proper permissions.
433 *
434 * @since 1.0.0
435 * @param string $api_key API key to test
436 * @return array Test result with status and message
437 */
438 private function test_google_analytics(string $api_key): array {
439 // Basic format validation
440 if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
441 return [
442 'status' => 'error',
443 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
444 ];
445 }
446
447 // Make a real API call to test the key
448 // Using PageSpeed Insights API as it uses the same API key and has a simple test endpoint
449 $test_url = add_query_arg([
450 'url' => 'https://example.com/',
451 'key' => $api_key,
452 'category' => 'performance',
453 'strategy' => 'mobile'
454 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
455
456 // Get timeout setting from settings or use default
457 $timeout = absint($this->settings->get('api_timeout') ?? 30);
458
459 $response = wp_remote_get($test_url, [
460 'timeout' => $timeout,
461 'headers' => [
462 'Accept' => 'application/json'
463 ],
464 'sslverify' => true
465 ]);
466
467 // Handle network/connection errors
468 if (is_wp_error($response)) {
469 return [
470 'status' => 'error',
471 'message' => 'Connection failed: ' . $response->get_error_message()
472 ];
473 }
474
475 $response_code = wp_remote_retrieve_response_code($response);
476 $response_body = wp_remote_retrieve_body($response);
477
478 // Handle HTTP errors
479 if ($response_code === 400) {
480 $error_data = json_decode($response_body, true);
481 $error_message = $error_data['error']['message'] ?? 'Bad request';
482
483 return [
484 'status' => 'error',
485 'message' => 'API key validation failed: ' . $error_message
486 ];
487 }
488
489 if ($response_code === 403) {
490 $error_data = json_decode($response_body, true);
491 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
492
493 // Check if it's an API key issue
494 if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
495 return [
496 'status' => 'error',
497 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
498 ];
499 }
500
501 return [
502 'status' => 'error',
503 'message' => 'Access denied: ' . $error_message
504 ];
505 }
506
507 if ($response_code === 429) {
508 return [
509 'status' => 'configured',
510 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
511 ];
512 }
513
514 if ($response_code !== 200) {
515 return [
516 'status' => 'error',
517 'message' => 'API request failed with status code: ' . $response_code
518 ];
519 }
520
521 // Validate response body
522 $data = json_decode($response_body, true);
523
524 if (json_last_error() !== JSON_ERROR_NONE) {
525 return [
526 'status' => 'error',
527 'message' => 'Invalid API response format'
528 ];
529 }
530
531 // Check if response has expected structure
532 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
533 return [
534 'status' => 'error',
535 'message' => 'Unexpected API response structure'
536 ];
537 }
538
539 // Success - API key is valid and working
540 return [
541 'status' => 'configured',
542 'message' => 'Google API key is valid and working correctly'
543 ];
544 }
545
546 /**
547 * Test Google Search Console API connection
548 *
549 * Makes a real API call to Google's PageSpeed Insights API to verify
550 * that the API key is valid and has proper permissions.
551 *
552 * @since 1.0.0
553 * @param string $api_key API key to test
554 * @return array Test result with status and message
555 */
556 private function test_search_console(string $api_key): array {
557 // Basic format validation
558 if (empty($api_key) || strlen($api_key) < 30 || !str_starts_with($api_key, 'AIza')) {
559 return [
560 'status' => 'error',
561 'message' => 'Invalid Google API key format. Key should start with "AIza" and be at least 30 characters.'
562 ];
563 }
564
565 // Make a real API call to test the key
566 // Using PageSpeed Insights API as it uses the same API key format
567 $test_url = add_query_arg([
568 'url' => 'https://example.com/',
569 'key' => $api_key,
570 'category' => 'seo',
571 'strategy' => 'desktop'
572 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
573
574 // Get timeout setting from settings or use default
575 $timeout = absint($this->settings->get('api_timeout') ?? 30);
576
577 $response = wp_remote_get($test_url, [
578 'timeout' => $timeout,
579 'headers' => [
580 'Accept' => 'application/json'
581 ],
582 'sslverify' => true
583 ]);
584
585 // Handle network/connection errors
586 if (is_wp_error($response)) {
587 return [
588 'status' => 'error',
589 'message' => 'Connection failed: ' . $response->get_error_message()
590 ];
591 }
592
593 $response_code = wp_remote_retrieve_response_code($response);
594 $response_body = wp_remote_retrieve_body($response);
595
596 // Handle HTTP errors
597 if ($response_code === 400) {
598 $error_data = json_decode($response_body, true);
599 $error_message = $error_data['error']['message'] ?? 'Bad request';
600
601 return [
602 'status' => 'error',
603 'message' => 'API key validation failed: ' . $error_message
604 ];
605 }
606
607 if ($response_code === 403) {
608 $error_data = json_decode($response_body, true);
609 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
610
611 // Check if it's an API key issue
612 if (stripos($error_message, 'API key') !== false || stripos($error_message, 'invalid') !== false) {
613 return [
614 'status' => 'error',
615 'message' => 'Invalid API key or insufficient permissions. Please verify your Google API key.'
616 ];
617 }
618
619 return [
620 'status' => 'error',
621 'message' => 'Access denied: ' . $error_message
622 ];
623 }
624
625 if ($response_code === 429) {
626 return [
627 'status' => 'configured',
628 'message' => 'API rate limit exceeded. The key is valid but you\'ve reached the quota limit.'
629 ];
630 }
631
632 if ($response_code !== 200) {
633 return [
634 'status' => 'error',
635 'message' => 'API request failed with status code: ' . $response_code
636 ];
637 }
638
639 // Validate response body
640 $data = json_decode($response_body, true);
641
642 if (json_last_error() !== JSON_ERROR_NONE) {
643 return [
644 'status' => 'error',
645 'message' => 'Invalid API response format'
646 ];
647 }
648
649 // Check if response has expected structure
650 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
651 return [
652 'status' => 'error',
653 'message' => 'Unexpected API response structure'
654 ];
655 }
656
657 // Success - API key is valid and working
658 return [
659 'status' => 'configured',
660 'message' => 'Google API key is valid and working correctly'
661 ];
662 }
663
664 /**
665 * Test Google PageSpeed API connection using OAuth token
666 *
667 * Makes a real API call to Google's PageSpeed Insights API using OAuth
668 * Bearer token to verify that the account is properly connected.
669 *
670 * @since 1.0.0
671 * @param string $access_token OAuth access token
672 * @return array Test result with status and message
673 */
674 private function test_pagespeed_oauth(string $access_token): array {
675 if (empty($access_token)) {
676 return [
677 'status' => 'error',
678 'message' => 'Google account not connected. Please connect your Google account to use PageSpeed Insights.'
679 ];
680 }
681
682 // Make a real API call to test the OAuth token with PageSpeed API
683 $test_url = add_query_arg([
684 'url' => 'https://example.com/',
685 'category' => 'performance',
686 'strategy' => 'mobile'
687 ], 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
688
689 // Get timeout setting from settings or use default
690 $timeout = absint($this->settings->get('api_timeout') ?? 30);
691
692 $response = wp_remote_get($test_url, [
693 'timeout' => $timeout,
694 'headers' => [
695 'Accept' => 'application/json',
696 'Authorization' => 'Bearer ' . $access_token
697 ],
698 'sslverify' => true
699 ]);
700
701 // Handle network/connection errors
702 if (is_wp_error($response)) {
703 return [
704 'status' => 'error',
705 'message' => 'Connection failed: ' . $response->get_error_message()
706 ];
707 }
708
709 $response_code = wp_remote_retrieve_response_code($response);
710 $response_body = wp_remote_retrieve_body($response);
711
712 // Handle HTTP errors
713 if ($response_code === 401) {
714 return [
715 'status' => 'error',
716 'message' => 'Google account token is expired or invalid. Please reconnect your Google account.'
717 ];
718 }
719
720 if ($response_code === 403) {
721 $error_data = json_decode($response_body, true);
722 $error_message = $error_data['error']['message'] ?? 'Access forbidden';
723 return [
724 'status' => 'error',
725 'message' => 'Access denied: ' . $error_message
726 ];
727 }
728
729 if ($response_code === 429) {
730 return [
731 'status' => 'configured',
732 'message' => 'API rate limit exceeded. The account is valid but you\'ve reached the quota limit.'
733 ];
734 }
735
736 if ($response_code !== 200) {
737 return [
738 'status' => 'error',
739 'message' => 'API request failed with status code: ' . $response_code
740 ];
741 }
742
743 // Validate response body
744 $data = json_decode($response_body, true);
745
746 if (json_last_error() !== JSON_ERROR_NONE) {
747 return [
748 'status' => 'error',
749 'message' => 'Invalid API response format'
750 ];
751 }
752
753 // Check if response has expected structure
754 if (!isset($data['lighthouseResult']) && !isset($data['loadingExperience'])) {
755 return [
756 'status' => 'error',
757 'message' => 'Unexpected API response structure'
758 ];
759 }
760
761 // Success - OAuth token is valid and working with PageSpeed API
762 return [
763 'status' => 'configured',
764 'message' => 'Google PageSpeed Insights connected successfully via Google OAuth'
765 ];
766 }
767
768 /**
769 * Disconnect Google Account
770 *
771 * @since 1.0.0
772 * @param WP_REST_Request $request Request object
773 * @return WP_REST_Response|WP_Error Response object
774 */
775 public function disconnect_google_account(WP_REST_Request $request): WP_REST_Response|WP_Error {
776 try {
777 // Best-effort revoke at Google so the refresh token (which never
778 // auto-expires) can't keep querying on the admin's behalf after
779 // disconnect. Failure here must not block local cleanup.
780 $token_to_revoke = $this->settings->get('google_refresh_token', '')
781 ?: $this->settings->get('google_access_token', '');
782 if (!empty($token_to_revoke)) {
783 wp_remote_post('https://oauth2.googleapis.com/revoke', [
784 'timeout' => 10,
785 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
786 'body' => ['token' => $token_to_revoke],
787 ]);
788 }
789
790 // Clear all Google-related settings
791 $this->settings->set('google_access_token', '');
792 $this->settings->set('google_refresh_token', '');
793 $this->settings->set('google_token_expires_in', '');
794 $this->settings->set('google_token_created', '');
795 $this->settings->set('google_account_connected', false);
796 // Also clear site selection
797 $this->settings->set('google_search_console_site', '');
798
799 // A deliberate disconnect is not a forced re-authorization.
800 delete_option('thinkrank_google_reconnect_required');
801
802 return new WP_REST_Response([
803 'success' => true,
804 'message' => 'Google account disconnected successfully'
805 ], 200);
806 } catch (\Exception $e) {
807 return new WP_Error(
808 'disconnect_failed',
809 'Failed to disconnect Google account: ' . $e->getMessage(),
810 ['status' => 500]
811 );
812 }
813 }
814
815
816 /**
817 * Get settings arguments for REST API
818 *
819 * @since 1.0.0
820 * @return array Settings arguments
821 */
822 private function get_settings_args(): array {
823 return [
824 'settings' => [
825 'required' => true,
826 'type' => 'object',
827 'description' => 'Integrations settings object'
828 ]
829 ];
830 }
831
832 /**
833 * Check read permissions
834 *
835 * @since 1.0.0
836 * @return bool Permission status
837 */
838 public function check_read_permissions(): bool {
839 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
840 }
841
842 /**
843 * Check manage permissions
844 *
845 * @since 1.0.0
846 * @return bool Permission status
847 */
848 public function check_manage_permissions(): bool {
849 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_settings');
850 }
851
852 /**
853 * Verify GA4 tracking
854 * Following ThinkRank API response patterns
855 *
856 * @since 1.0.0
857 * @param WP_REST_Request $request Request object
858 * @return WP_REST_Response|WP_Error Response object
859 */
860 public function verify_ga4_tracking(WP_REST_Request $request): WP_REST_Response|WP_Error {
861 try {
862 $measurement_id = $request->get_param('measurement_id');
863
864 if (empty($measurement_id)) {
865 return new WP_Error(
866 'missing_measurement_id',
867 'Measurement ID is required',
868 ['status' => 400]
869 );
870 }
871
872 // Load tracking manager
873 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
874 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
875 }
876
877 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
878 $verification_result = $tracking_manager->verify_tracking($measurement_id);
879
880 return new WP_REST_Response([
881 'success' => true,
882 'data' => $verification_result,
883 'message' => 'Tracking verification completed'
884 ], 200);
885 } catch (\Exception $e) {
886 return new WP_Error(
887 'verification_failed',
888 'Tracking verification failed: ' . $e->getMessage(),
889 ['status' => 500]
890 );
891 }
892 }
893
894 /**
895 * Detect GA4 conflicts
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 detect_ga4_conflicts(WP_REST_Request $request): WP_REST_Response|WP_Error {
903 try {
904 // Load tracking manager
905 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
906 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
907 }
908
909 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
910 $conflicts = $tracking_manager->detect_existing_tracking();
911
912 return new WP_REST_Response([
913 'success' => true,
914 'data' => [
915 'conflicts' => $conflicts,
916 'has_conflicts' => !empty($conflicts)
917 ],
918 'message' => 'Conflict detection completed'
919 ], 200);
920 } catch (\Exception $e) {
921 return new WP_Error(
922 'conflict_detection_failed',
923 'Conflict detection failed: ' . $e->getMessage(),
924 ['status' => 500]
925 );
926 }
927 }
928 /**
929 * Get Search Console Sites
930 *
931 * @since 1.0.0
932 * @param WP_REST_Request $request Request object
933 * @return WP_REST_Response Response object
934 */
935 public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
936 try {
937 // The verified-sites list changes rarely but costs a live Google
938 // round-trip — serve from a 30-minute transient so the Google
939 // Services screen doesn't hit Google on every render.
940 $sites_cache_key = 'thinkrank_gsc_sites_list';
941 $cached_sites = get_transient($sites_cache_key);
942 if (is_array($cached_sites)) {
943 return new WP_REST_Response($cached_sites, 200);
944 }
945
946 // Ensure Analytics_Manager is loaded for proactive token refresh
947 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
948 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
949 }
950
951 // Proactively refresh token if expired — prevents 401 errors on initial load
952 \ThinkRank\SEO\Analytics_Manager::ensure_fresh_token();
953
954 // Get access token (now guaranteed fresh if refresh_token is available)
955 $access_token = $this->settings->get('google_access_token');
956 $api_key = $this->settings->get('google_search_console_api_key');
957
958 if (empty($access_token)) {
959 return new WP_REST_Response([
960 'success' => false,
961 'message' => 'Google account not connected'
962 ], 401);
963 }
964
965 // Initialize Search Console Client
966 if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) {
967 require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php';
968 }
969
970 // Ensure Analytics_Manager is loaded for token refresh
971 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
972 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
973 }
974
975 // We need a client that can use the access token
976 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
977 $api_key ?: '',
978 30,
979 $access_token
980 );
981
982 $max_retries = 1;
983 $retry_count = 0;
984 $sites_data = [];
985
986 while ($retry_count <= $max_retries) {
987 try {
988 // Fetch sites
989 $sites_data = $client->list_sites();
990 break; // Success
991 } catch (\Exception $e) {
992 // Check for 401 error
993 if ($e->getCode() === 401 && $retry_count < $max_retries) {
994 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
995 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
996 error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
997 }
998
999 // Initialize Analytics Manager to handle token refresh
1000 $analytics_manager = new \ThinkRank\SEO\Analytics_Manager();
1001 $analytics_manager->refresh_access_token(true); // Force refresh
1002
1003 // Get new token
1004 $new_access_token = $this->settings->get('google_access_token');
1005 // Update client with new token
1006 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1007 $api_key ?: '',
1008 30,
1009 $new_access_token
1010 );
1011
1012 $retry_count++;
1013 continue;
1014 }
1015
1016 throw $e;
1017 }
1018 }
1019
1020 $sites = [];
1021 if (isset($sites_data['siteEntry']) && is_array($sites_data['siteEntry'])) {
1022 foreach ($sites_data['siteEntry'] as $site) {
1023 $sites[] = [
1024 'siteUrl' => $site['siteUrl'] ?? '',
1025 'permissionLevel' => $site['permissionLevel'] ?? 'siteOwner'
1026 ];
1027 }
1028 }
1029
1030 $payload = [
1031 'success' => true,
1032 'data' => [
1033 'sites' => $sites
1034 ],
1035 'message' => 'Search Console sites retrieved successfully'
1036 ];
1037
1038 // Cache successes only — errors must stay retryable.
1039 if (!empty($sites)) {
1040 set_transient($sites_cache_key, $payload, 30 * MINUTE_IN_SECONDS);
1041 }
1042
1043 return new WP_REST_Response($payload, 200);
1044 } catch (\Exception $e) {
1045 $code = $e->getCode();
1046 $status = ($code >= 400 && $code < 600) ? $code : 500;
1047
1048 return new WP_REST_Response([
1049 'success' => false,
1050 'message' => 'Failed to retrieve Search Console sites: ' . $e->getMessage()
1051 ], $status);
1052 }
1053 }
1054 }
1055