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

1,163 lines 43.1 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 // 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 * Check permissions for credential-managing operations.
896 *
897 * Writing provider API keys, disconnecting Google (a server-side token
898 * revoke) and running live connection tests manage the site's third-party
899 * credentials, so they require an administrator — `thinkrank_settings` is
900 * delegatable to non-admin roles through the Role Manager. Mirrors the
901 * pattern used by the brand-visibility and AI-insights key writes.
902 *
903 * @since 1.29.0
904 * @return bool Permission status
905 */
906 public function check_credential_permissions(): bool {
907 return current_user_can('manage_options');
908 }
909
910 /**
911 * Verify GA4 tracking
912 * Following ThinkRank API response patterns
913 *
914 * @since 1.0.0
915 * @param WP_REST_Request $request Request object
916 * @return WP_REST_Response|WP_Error Response object
917 */
918 public function verify_ga4_tracking(WP_REST_Request $request): WP_REST_Response|WP_Error {
919 try {
920 // Empty is allowed and meaningful: verify_tracking() then reads the
921 // homepage and discovers whichever GA4 ID is actually serving. The
922 // old 400 made verification impossible on OAuth-connected sites
923 // that never typed an ID in — precisely the reported case (#250).
924 $measurement_id = (string) ( $request->get_param('measurement_id') ?? '' );
925
926 // Load tracking manager
927 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
928 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
929 }
930
931 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
932 $verification_result = $tracking_manager->verify_tracking($measurement_id);
933
934 return new WP_REST_Response([
935 'success' => true,
936 'data' => $verification_result,
937 'message' => 'Tracking verification completed'
938 ], 200);
939 } catch (\Exception $e) {
940 return new WP_Error(
941 'verification_failed',
942 'Tracking verification failed: ' . $e->getMessage(),
943 ['status' => 500]
944 );
945 }
946 }
947
948 /**
949 * Detect GA4 conflicts
950 * Following ThinkRank API response patterns
951 *
952 * @since 1.0.0
953 * @param WP_REST_Request $request Request object
954 * @return WP_REST_Response|WP_Error Response object
955 */
956 public function detect_ga4_conflicts(WP_REST_Request $request): WP_REST_Response|WP_Error {
957 try {
958 // Load tracking manager
959 if (!class_exists('ThinkRank\\Frontend\\Google_Analytics_Tracking_Manager')) {
960 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/class-google-analytics-tracking-manager.php';
961 }
962
963 $tracking_manager = new \ThinkRank\Frontend\Google_Analytics_Tracking_Manager();
964 $conflicts = $tracking_manager->detect_existing_tracking();
965
966 return new WP_REST_Response([
967 'success' => true,
968 'data' => [
969 'conflicts' => $conflicts,
970 'has_conflicts' => !empty($conflicts)
971 ],
972 'message' => 'Conflict detection completed'
973 ], 200);
974 } catch (\Exception $e) {
975 return new WP_Error(
976 'conflict_detection_failed',
977 'Conflict detection failed: ' . $e->getMessage(),
978 ['status' => 500]
979 );
980 }
981 }
982 /**
983 * Fingerprint the currently connected Google account.
984 *
985 * Prefers the refresh token: it is issued once per authorization grant and
986 * survives every access-token rotation, so the cache stays warm for a whole
987 * connection but changes the moment a different account authorizes. Falls
988 * back to the access token when no refresh token was granted, which merely
989 * shortens the effective cache life to one token lifetime.
990 *
991 * @since 1.28.0
992 * @return string Non-reversible fingerprint, empty string when disconnected.
993 */
994 private function get_google_account_fingerprint(): string {
995 $token = $this->settings->get('google_refresh_token', '')
996 ?: $this->settings->get('google_access_token', '');
997
998 return empty($token) ? '' : md5((string) $token);
999 }
1000
1001 /**
1002 * Drop the cached Search Console property list.
1003 *
1004 * Public and static so the OAuth paths — which run outside this controller
1005 * and after the credentials are gone — can invalidate the list on connect,
1006 * disconnect and revoke.
1007 *
1008 * @since 1.28.0
1009 * @return void
1010 */
1011 public static function purge_search_console_sites_cache(): void {
1012 delete_transient(self::SITES_CACHE_KEY);
1013 }
1014
1015 /**
1016 * Get Search Console Sites
1017 *
1018 * @since 1.0.0
1019 * @param WP_REST_Request $request Request object
1020 * @return WP_REST_Response Response object
1021 *
1022 * @throws \Exception On failure.
1023 */
1024 public function get_search_console_sites(WP_REST_Request $request): WP_REST_Response {
1025 try {
1026 // Ensure Analytics_Manager is loaded for proactive token refresh
1027 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
1028 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
1029 }
1030
1031 // Proactively refresh token if expired — prevents 401 errors on initial load
1032 \ThinkRank\SEO\Analytics_Manager::ensure_fresh_token();
1033
1034 // Get access token (now guaranteed fresh if refresh_token is available)
1035 $access_token = $this->settings->get('google_access_token');
1036 $api_key = $this->settings->get('google_search_console_api_key');
1037
1038 if (empty($access_token)) {
1039 return new WP_REST_Response([
1040 'success' => false,
1041 'message' => 'Google account not connected'
1042 ], 401);
1043 }
1044
1045 // The verified-sites list changes rarely but costs a live Google
1046 // round-trip — serve from a 30-minute transient so the Google
1047 // Services screen doesn't hit Google on every render. The cache is
1048 // only honoured for the account that wrote it: reconnecting as a
1049 // different Google account must never hand back the previous
1050 // account's properties, which reads as "my site is missing".
1051 $account = $this->get_google_account_fingerprint();
1052 $cached_sites = get_transient(self::SITES_CACHE_KEY);
1053
1054 if (
1055 !$request->get_param('refresh')
1056 && is_array($cached_sites)
1057 && isset($cached_sites['account'], $cached_sites['payload'])
1058 && hash_equals($account, (string) $cached_sites['account'])
1059 ) {
1060 return new WP_REST_Response($cached_sites['payload'], 200);
1061 }
1062
1063 // Initialize Search Console Client
1064 if (!class_exists('ThinkRank\\Integrations\\Google_Search_Console_Client')) {
1065 require_once THINKRANK_PLUGIN_DIR . 'includes/integrations/class-google-search-console-client.php';
1066 }
1067
1068 // Ensure Analytics_Manager is loaded for token refresh
1069 if (!class_exists('ThinkRank\\SEO\\Analytics_Manager')) {
1070 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-analytics-manager.php';
1071 }
1072
1073 // We need a client that can use the access token
1074 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1075 $api_key ?: '',
1076 30,
1077 $access_token
1078 );
1079
1080 $max_retries = 1;
1081 $retry_count = 0;
1082 $sites_data = [];
1083
1084 while ($retry_count <= $max_retries) {
1085 try {
1086 // Fetch sites
1087 $sites_data = $client->list_sites();
1088 break; // Success
1089 } catch (\Exception $e) {
1090 // Check for 401 error
1091 if ($e->getCode() === 401 && $retry_count < $max_retries) {
1092 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1093 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1094 error_log('ThinkRank: 401 detected in get_search_console_sites. Forcing token refresh...');
1095 }
1096
1097 // Initialize Analytics Manager to handle token refresh
1098 $analytics_manager = new \ThinkRank\SEO\Analytics_Manager();
1099 $analytics_manager->refresh_access_token(true); // Force refresh
1100
1101 // Get new token
1102 $new_access_token = $this->settings->get('google_access_token');
1103 // Update client with new token
1104 $client = new \ThinkRank\Integrations\Google_Search_Console_Client(
1105 $api_key ?: '',
1106 30,
1107 $new_access_token
1108 );
1109
1110 $retry_count++;
1111 continue;
1112 }
1113
1114 throw $e;
1115 }
1116 }
1117
1118 $sites = [];
1119 if (isset($sites_data['siteEntry']) && is_array($sites_data['siteEntry'])) {
1120 foreach ($sites_data['siteEntry'] as $site) {
1121 $sites[] = [
1122 'siteUrl' => $site['siteUrl'] ?? '',
1123 'permissionLevel' => $site['permissionLevel'] ?? 'siteOwner'
1124 ];
1125 }
1126 }
1127
1128 $payload = [
1129 'success' => true,
1130 'data' => [
1131 'sites' => $sites
1132 ],
1133 'message' => 'Search Console sites retrieved successfully'
1134 ];
1135
1136 // Cache successes only — errors must stay retryable. Recompute the
1137 // fingerprint: the 401 retry above may have rotated the access
1138 // token, and the cache must be stamped with the account it came
1139 // from, not the one we started the request with.
1140 if (!empty($sites)) {
1141 set_transient(
1142 self::SITES_CACHE_KEY,
1143 [
1144 'account' => $this->get_google_account_fingerprint(),
1145 'payload' => $payload,
1146 ],
1147 30 * MINUTE_IN_SECONDS
1148 );
1149 }
1150
1151 return new WP_REST_Response($payload, 200);
1152 } catch (\Exception $e) {
1153 $code = $e->getCode();
1154 $status = ($code >= 400 && $code < 600) ? $code : 500;
1155
1156 return new WP_REST_Response([
1157 'success' => false,
1158 'message' => 'Failed to retrieve Search Console sites: ' . $e->getMessage()
1159 ], $status);
1160 }
1161 }
1162 }
1163