PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.13
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.13
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-connect-manager.php

class-metasync-connect-manager.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.13, at includes/class-metasync-connect-manager.php

1,070 lines 39.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * SearchAtlas Connect / SSO Authentication Manager
8 *
9 * Handles all Search Atlas connect flow, token generation/validation,
10 * JWT management, session management, and authentication reset logic.
11 *
12 * @package Metasync
13 * @subpackage Metasync/includes
14 */
15 class Metasync_Connect_Manager
16 {
17 private static $instance = null;
18
19 public static function instance()
20 {
21 if (null === self::$instance) {
22 self::$instance = new self();
23 }
24 return self::$instance;
25 }
26
27 private function __construct() {}
28
29 // ------------------------------------------------------------------
30 // Token context validation
31 // ------------------------------------------------------------------
32
33 public function validate_searchatlas_context($token_data)
34 {
35 if (isset($token_data['site_url']) && $token_data['site_url'] !== get_site_url()) {
36 return false;
37 }
38
39 return true;
40 }
41
42 /**
43 * Check if IP validation should be enforced
44 */
45 public function should_validate_ip()
46 {
47 $settings = Metasync::get_option('general');
48 return isset($settings['enforce_ip_validation']) ? (bool)$settings['enforce_ip_validation'] : false;
49 }
50
51 /**
52 * Check if user agents are incompatible (not just version differences)
53 */
54 public function are_user_agents_incompatible($old_ua, $new_ua)
55 {
56 $old_browser = $this->extract_browser_name($old_ua);
57 $new_browser = $this->extract_browser_name($new_ua);
58
59 return $old_browser !== $new_browser && !empty($old_browser) && !empty($new_browser);
60 }
61
62 /**
63 * Extract browser name from user agent string
64 */
65 public function extract_browser_name($ua)
66 {
67 if (stripos($ua, 'Chrome') !== false) return 'Chrome';
68 if (stripos($ua, 'Firefox') !== false) return 'Firefox';
69 if (stripos($ua, 'Safari') !== false) return 'Safari';
70 if (stripos($ua, 'Edge') !== false) return 'Edge';
71 if (stripos($ua, 'Opera') !== false) return 'Opera';
72 return 'Unknown';
73 }
74
75 // ------------------------------------------------------------------
76 // Plugin Auth Token helpers
77 // ------------------------------------------------------------------
78
79 /**
80 * Generate Search Atlas WordPress Connect Token.
81 *
82 * Returns the Plugin Auth Token used to authenticate with the Search Atlas platform
83 * during the 1-click connect flow. This token is used ONLY to retrieve the Search Atlas
84 * API key and Otto UUID — it does NOT log anyone into WordPress.
85 */
86 public function generate_searchatlas_wp_connect_token($regenerate = false)
87 {
88 $general_options = Metasync::get_option('general') ?? [];
89 $plugin_auth_token = $general_options['apikey'] ?? '';
90
91 if (empty($plugin_auth_token)) {
92 error_log('MetaSync ERROR: Plugin Auth Token missing from options - should have been generated during activation');
93 return false;
94 }
95
96 return $plugin_auth_token;
97 }
98
99 /**
100 * Ensure Plugin Auth Token exists before Search Atlas connect authentication.
101 * Auto-generates if missing to ensure smooth connect flow.
102 */
103 public function ensure_plugin_auth_token_exists()
104 {
105 $options = Metasync::get_option();
106 $current_plugin_auth_token = $options['general']['apikey'] ?? '';
107
108 if (empty($current_plugin_auth_token)) {
109
110 $new_plugin_auth_token = wp_generate_password(32, false, false);
111
112 if (!isset($options['general'])) {
113 $options['general'] = [];
114 }
115
116 $options['general']['apikey'] = $new_plugin_auth_token;
117
118 $save_result = Metasync::set_option($options);
119
120 if ($save_result) {
121 Metasync::log_api_key_event('auto_generated_for_sa_connect', 'plugin_auth_token', array(
122 'new_token_prefix' => substr($new_plugin_auth_token, 0, 8) . '...',
123 'triggered_by' => 'sa_connect_button',
124 'reason' => 'Plugin Auth Token was missing before Search Atlas connect authentication'
125 ), 'info');
126
127 } else {
128 global $wpdb;
129 if (class_exists('Metasync_Error_Logger') && !empty($wpdb->last_error)) {
130 Metasync_Error_Logger::log(
131 Metasync_Error_Logger::CATEGORY_DATABASE_ERROR,
132 Metasync_Error_Logger::SEVERITY_CRITICAL,
133 'Failed to save plugin auth token to database',
134 [
135 'option_name' => Metasync::option_name,
136 'wpdb_error' => $wpdb->last_error,
137 'wpdb_last_query' => $wpdb->last_query,
138 'operation' => 'ensure_plugin_auth_token_exists',
139 'triggered_by' => 'sso_connect_button'
140 ]
141 );
142 }
143
144 throw new Exception('Failed to generate required authentication token');
145 }
146 }
147 }
148
149 /**
150 * Refresh Plugin Auth Token (AJAX endpoint)
151 */
152 public function refresh_plugin_auth_token()
153 {
154 if (!wp_verify_nonce($_POST['nonce'], 'metasync_refresh_plugin_auth_token')) {
155 wp_send_json_error(array('message' => 'Invalid nonce'));
156 return;
157 }
158
159 if (!Metasync::current_user_has_plugin_access()) {
160 wp_send_json_error(array('message' => 'Insufficient permissions'));
161 return;
162 }
163
164 try {
165 $new_plugin_auth_token = wp_generate_password(32, false, false);
166
167 $options = Metasync::get_option();
168 if (!isset($options['general'])) {
169 $options['general'] = [];
170 }
171 $options['general']['apikey'] = $new_plugin_auth_token;
172
173 $save_result = Metasync::set_option($options);
174
175 if ($save_result) {
176 Metasync::log_api_key_event('token_refresh', 'plugin_auth_token', array(
177 'new_token_prefix' => substr($new_plugin_auth_token, 0, 8) . '...',
178 'triggered_by' => 'manual_refresh_button'
179 ), 'info');
180
181 do_action('metasync_trigger_immediate_heartbeat', 'Plugin Auth Token refresh - new token generated');
182
183 wp_send_json_success(array(
184 'new_token' => $new_plugin_auth_token,
185 'message' => 'Plugin Auth Token refreshed successfully'
186 ));
187 } else {
188 wp_send_json_error(array('message' => 'Failed to save new token'));
189 }
190
191 } catch (Exception $e) {
192 error_log('Plugin Auth Token Refresh Error: ' . $e->getMessage());
193 wp_send_json_error(array('message' => 'Error generating new token'));
194 }
195 }
196
197 /**
198 * Get current Plugin Auth Token (AJAX endpoint for UI updates)
199 */
200 public function get_plugin_auth_token()
201 {
202 if (!wp_verify_nonce($_POST['nonce'], 'metasync_sa_connect_nonce')) {
203 wp_send_json_error(array('message' => 'Invalid nonce'));
204 return;
205 }
206
207 if (!Metasync::current_user_has_plugin_access()) {
208 wp_send_json_error(array('message' => 'Insufficient permissions'));
209 return;
210 }
211
212 try {
213 $options = Metasync::get_option();
214 $current_plugin_auth_token = $options['general']['apikey'] ?? '';
215
216 if (!empty($current_plugin_auth_token)) {
217 wp_send_json_success(array(
218 'plugin_auth_token' => $current_plugin_auth_token,
219 'message' => 'Plugin Auth Token retrieved successfully'
220 ));
221 } else {
222 wp_send_json_error(array('message' => 'Plugin Auth Token not found'));
223 }
224
225 } catch (Exception $e) {
226 error_log('Get Plugin Auth Token Error: ' . $e->getMessage());
227 wp_send_json_error(array('message' => 'Error retrieving Plugin Auth Token'));
228 }
229 }
230
231 // ------------------------------------------------------------------
232 // Search Atlas Connect URL & polling
233 // ------------------------------------------------------------------
234
235 /**
236 * Generate Search Atlas Connect URL (1-click connect).
237 *
238 * AJAX action: wp_ajax_metasync_generate_connect_url
239 */
240 public function generate_searchatlas_connect_url()
241 {
242 if (!current_user_can('manage_options')) {
243 wp_send_json_error(array('message' => 'Insufficient permissions. Administrator access required.'));
244 return;
245 }
246
247 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce')) {
248 wp_send_json_error(array('message' => 'Invalid nonce - please refresh the page and try again'));
249 return;
250 }
251
252 $rate_limit_key = 'metasync_sa_connect_rate_' . get_current_user_id();
253 $rate_limit_count = get_transient($rate_limit_key);
254 if ($rate_limit_count !== false && $rate_limit_count >= 10) {
255 wp_send_json_error(array('message' => 'Too many connect requests. Please wait a few minutes before trying again.'));
256 return;
257 }
258 set_transient($rate_limit_key, ($rate_limit_count === false ? 1 : $rate_limit_count + 1), 300);
259
260 try {
261 $this->ensure_plugin_auth_token_exists();
262
263 $sa_connect_token = $this->create_searchatlas_nonce_token();
264
265 if (!$sa_connect_token) {
266 wp_send_json_error(array('message' => 'Failed to create authentication token'));
267 return;
268 }
269
270 $domain = str_replace('://www.', '://', get_site_url());
271
272 $dashboard_domain = Metasync_Admin::get_effective_dashboard_domain();
273
274 $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query([
275 'nonce_token' => $sa_connect_token,
276 'domain' => $domain,
277 'callback_url' => get_rest_url(null, 'metasync/v1/searchatlas/connect/callback'),
278 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
279 ]);
280
281 wp_send_json_success(array(
282 'connect_url' => $sa_connect_url,
283 'nonce_token' => $sa_connect_token,
284 'debug_info' => array(
285 'dashboard_domain' => $dashboard_domain,
286 'site_domain' => $domain,
287 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
288 )
289 ));
290
291 } catch (Exception $e) {
292 wp_send_json_error(array('message' => 'Failed to generate Search Atlas connect URL: ' . $e->getMessage()));
293 }
294 }
295
296 /**
297 * Check Search Atlas Connect Status (polling endpoint).
298 *
299 * AJAX action: wp_ajax_metasync_check_connect_status
300 */
301 public function check_searchatlas_connect_status()
302 {
303 if (!current_user_can('manage_options')) {
304 wp_send_json_error(array('message' => 'Insufficient permissions. Administrator access required.'));
305 return;
306 }
307
308 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce')) {
309 wp_send_json_error(array('message' => 'Invalid nonce'));
310 return;
311 }
312
313 $nonce_token = isset($_POST['nonce_token']) ? sanitize_text_field(wp_unslash($_POST['nonce_token'])) : '';
314
315 // Check if THIS specific nonce was successfully processed
316 // This prevents false positives from background sync/heartbeat activity
317 $success_key = 'metasync_sa_connect_success_' . md5($nonce_token);
318 $this_auth_completed = get_transient($success_key);
319
320 // Fallback: on sites with external object cache, the success flag written
321 // by the REST callback (SA server process) is invisible to this AJAX poll
322 // (admin user process). Read directly from wp_options as fallback.
323 if (!$this_auth_completed && wp_using_ext_object_cache()) {
324 global $wpdb;
325
326 // Check expiry first
327 $timeout = $wpdb->get_var(
328 $wpdb->prepare(
329 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
330 '_transient_timeout_' . $success_key
331 )
332 );
333
334 if ($timeout && (int) $timeout < time()) {
335 // Expired — clean up
336 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
337 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
338 } else {
339 $db_value = $wpdb->get_var(
340 $wpdb->prepare(
341 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
342 '_transient_' . $success_key
343 )
344 );
345 if ($db_value) {
346 $this_auth_completed = true;
347 }
348 }
349 }
350
351 if ($this_auth_completed) {
352 // Delete the transient (one-time use) to prevent replay
353 delete_transient($success_key);
354 // Also clean up DB fallback rows
355 if (wp_using_ext_object_cache()) {
356 global $wpdb;
357 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
358 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
359 }
360
361 // Get current settings to return API key
362 $general_settings = Metasync::get_option('general') ?? [];
363
364 wp_send_json_success(array(
365 'updated' => true,
366 'api_key' => $general_settings['searchatlas_api_key'], // Return full API key
367 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '', // Return OTTO UUID for UI update
368 'status_code' => 200,
369 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
370 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
371 ));
372 }
373
374 wp_send_json_success(array('updated' => false));
375 }
376
377 // ------------------------------------------------------------------
378 // Nonce / encrypted token helpers
379 // ------------------------------------------------------------------
380
381 /**
382 * Create Search Atlas Connect Nonce Token.
383 *
384 * Generates a unique, time-limited (15 min), single-use nonce token used to
385 * identify the connect session when Search Atlas calls back with the API key
386 * and Otto UUID.
387 */
388 public function create_searchatlas_nonce_token()
389 {
390 $general_options = Metasync::get_option('general') ?? [];
391 $plugin_auth_token = $general_options['apikey'] ?? '';
392
393 if (empty($plugin_auth_token)) {
394 error_log('MetaSync ERROR: Plugin Auth Token missing from options');
395 return false;
396 }
397
398 $random_bytes = wp_generate_password(32, false, false);
399 $timestamp = time();
400 $user_id = get_current_user_id();
401
402 $token_data = $random_bytes . '|' . $timestamp . '|' . $user_id . '|' . get_site_url();
403 $sa_connect_token = hash_hmac('sha256', $token_data, $plugin_auth_token . wp_salt('auth'));
404
405 $token_metadata = array(
406 'created' => $timestamp,
407 'expires' => $timestamp + 900,
408 'user_id' => $user_id,
409 'site_url' => get_site_url(),
410 'ip' => $this->get_client_ip(),
411 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 0, 100) : '',
412 'used' => false,
413 'callback_used' => false,
414 'version' => '3.0'
415 );
416
417 $transient_key = 'metasync_sa_connect_token_' . substr(hash('sha256', $sa_connect_token), 0, 32);
418 set_transient($transient_key, $token_metadata, 900);
419
420 set_transient('metasync_sa_connect_active_' . $sa_connect_token, $transient_key, 900);
421
422 // When external object cache (Redis/Memcached/LiteSpeed) is active,
423 // set_transient() writes to cache ONLY — never to wp_options. The REST
424 // callback from SA servers runs in a different process/cache context and
425 // cannot see the cached value. Write directly to DB as a fallback.
426 if (wp_using_ext_object_cache()) {
427 global $wpdb;
428 $active_option = '_transient_metasync_sa_connect_active_' . $sa_connect_token;
429 $metadata_option = '_transient_' . $transient_key;
430 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
431 $timeout_metadata = '_transient_timeout_' . $transient_key;
432 $expires = time() + 900;
433
434 $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no'));
435 $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no'));
436 $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no'));
437 $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no'));
438 }
439
440 return $sa_connect_token;
441 }
442
443 /**
444 * Get client IP address securely
445 */
446 public function get_client_ip()
447 {
448 $ip_headers = array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR');
449
450 foreach ($ip_headers as $header) {
451 if (!empty($_SERVER[$header])) {
452 $ip = $_SERVER[$header];
453 if (strpos($ip, ',') !== false) {
454 $ip = trim(explode(',', $ip)[0]);
455 }
456 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
457 return $ip;
458 }
459 }
460 }
461
462 return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
463 }
464
465 /**
466 * Create encrypted Search Atlas connect token with embedded metadata
467 */
468 public function create_encrypted_searchatlas_token($metadata = array())
469 {
470 $payload = array_merge(array(
471 'iat' => time(),
472 'exp' => time() + 1800,
473 'iss' => get_site_url(),
474 'aud' => 'search-atlas-connect',
475 'sub' => 'searchatlas-authentication',
476 'jti' => wp_generate_password(16, false),
477 'nonce' => wp_generate_password(16, false),
478 'version' => '2.0'
479 ), $metadata);
480
481 return $this->wp_encrypt_token($payload);
482 }
483
484 /**
485 * Encrypt token using WordPress SALTs
486 */
487 public function wp_encrypt_token($payload)
488 {
489 try {
490 $serialized = serialize($payload);
491
492 $key_material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
493 $encryption_key = hash('sha256', $key_material, true);
494
495 $iv = random_bytes(16);
496
497 $encrypted = openssl_encrypt($serialized, 'AES-256-CBC', $encryption_key, OPENSSL_RAW_DATA, $iv);
498
499 if ($encrypted === false) {
500 throw new Exception('Encryption failed');
501 }
502
503 $result = $iv . $encrypted;
504
505 return base64_encode($result);
506
507 } catch (Exception $e) {
508 return false;
509 }
510 }
511
512 /**
513 * Decrypt token using WordPress SALTs
514 */
515 public function wp_decrypt_token($encrypted_token)
516 {
517 try {
518 $data = base64_decode($encrypted_token, true);
519
520 if ($data === false || strlen($data) < 16) {
521 return false;
522 }
523
524 $iv = substr($data, 0, 16);
525 $encrypted = substr($data, 16);
526
527 $key_material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
528 $encryption_key = hash('sha256', $key_material, true);
529
530 $serialized = openssl_decrypt($encrypted, 'AES-256-CBC', $encryption_key, OPENSSL_RAW_DATA, $iv);
531
532 if ($serialized === false) {
533 return false;
534 }
535
536 $payload = unserialize($serialized, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
537
538 if (!is_array($payload) || !isset($payload['exp'], $payload['iat'])) {
539 return false;
540 }
541
542 if ($payload['exp'] < time()) {
543 return false;
544 }
545
546 return $payload;
547
548 } catch (Exception $e) {
549 return false;
550 }
551 }
552
553 // ------------------------------------------------------------------
554 // Cleanup helpers
555 // ------------------------------------------------------------------
556
557 /**
558 * @deprecated No longer needed with simplified token system
559 */
560 public function cleanup_searchatlas_nonce_tokens()
561 {
562 return 0;
563 }
564
565 /**
566 * Cleanup Search Atlas connect rate limiting data
567 */
568 public function cleanup_searchatlas_rate_limits()
569 {
570 global $wpdb;
571
572 try {
573 $rate_limit_transients = $wpdb->get_results(
574 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
575 ARRAY_A
576 );
577
578 $cleaned_count = 0;
579
580 foreach ($rate_limit_transients as $transient) {
581 $transient_name = str_replace('_transient_', '', $transient['option_name']);
582 delete_transient($transient_name);
583 $cleaned_count++;
584 }
585
586 return $cleaned_count;
587
588 } catch (Exception $e) {
589 return 0;
590 }
591 }
592
593 /**
594 * Clear cached JWT tokens
595 */
596 public function clear_jwt_token_cache()
597 {
598 global $wpdb;
599
600 $deleted = $wpdb->query(
601 $wpdb->prepare(
602 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
603 '_transient_metasync_jwt_token_%'
604 )
605 );
606
607 $wpdb->query(
608 $wpdb->prepare(
609 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
610 '_transient_timeout_metasync_jwt_token_%'
611 )
612 );
613 }
614
615 // ------------------------------------------------------------------
616 // JWT token management
617 // ------------------------------------------------------------------
618
619 /**
620 * Get active JWT token for the plugin.
621 * Public static method accessible from anywhere in the plugin.
622 *
623 * @param bool $force_refresh Force generation of new token even if cached one exists
624 * @return string|false JWT token on success, false on failure
625 */
626 public static function get_active_jwt_token($force_refresh = false)
627 {
628 $general_options = Metasync::get_option('general') ?? [];
629 $api_key = $general_options['searchatlas_api_key'] ?? '';
630
631 if (empty($api_key)) {
632 return false;
633 }
634
635 if (!$force_refresh) {
636 $cache_key = 'metasync_jwt_token_' . md5($api_key);
637 $cached_token_data = get_transient($cache_key);
638
639 if ($cached_token_data && is_array($cached_token_data)) {
640 $expires_with_buffer = $cached_token_data['expires'] - 300;
641 if (time() < $expires_with_buffer && !empty($cached_token_data['token'])) {
642 return $cached_token_data['token'];
643 }
644 }
645 }
646
647 return self::instance()->get_fresh_jwt_token();
648 }
649
650 /**
651 * Get fresh JWT token from Search Atlas API with caching
652 *
653 * @return string|false JWT token on success, false on failure
654 */
655 public function get_fresh_jwt_token()
656 {
657 $general_options = Metasync::get_option('general') ?? [];
658 $api_key = $general_options['searchatlas_api_key'] ?? '';
659
660 if (empty($api_key)) {
661 return false;
662 }
663
664 $cache_key = 'metasync_jwt_token_' . md5($api_key);
665 $cached_token_data = get_transient($cache_key);
666
667 if ($cached_token_data && is_array($cached_token_data)) {
668 $expires_with_buffer = $cached_token_data['expires'] - 300;
669 if (time() < $expires_with_buffer && !empty($cached_token_data['token'])) {
670 return $cached_token_data['token'];
671 }
672 }
673
674 $api_domain = class_exists('Metasync_Endpoint_Manager')
675 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
676 : Metasync::API_DOMAIN;
677 $url = $api_domain . '/api/customer/account/generate-jwt-from-api-key/';
678
679 $args = array(
680 'method' => 'POST',
681 'headers' => array(
682 'X-API-KEY' => $api_key,
683 'Content-Type' => 'application/json'
684 ),
685 'timeout' => 15
686 );
687
688 try {
689 $response = wp_remote_post($url, $args);
690
691 if (is_wp_error($response)) {
692 error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message());
693 return false;
694 }
695
696 $response_code = wp_remote_retrieve_response_code($response);
697 $response_body = wp_remote_retrieve_body($response);
698
699 if ($response_code !== 200) {
700 error_log('MetaSync: JWT token API returned error code ' . $response_code);
701 return false;
702 }
703
704 $data = json_decode($response_body, true);
705
706 if (!$data || !isset($data['token'], $data['expires'])) {
707 error_log('MetaSync: Invalid JWT token API response format');
708 return false;
709 }
710
711 $token_data = array(
712 'token' => $data['token'],
713 'expires' => $data['expires'],
714 'created_at' => time()
715 );
716
717 $cache_duration = min($data['expires'] - time(), 24 * 3600);
718 set_transient($cache_key, $token_data, $cache_duration);
719
720 return $data['token'];
721
722 } catch (Exception $e) {
723 error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage());
724 return false;
725 }
726 }
727
728 // ------------------------------------------------------------------
729 // Authentication reset
730 // ------------------------------------------------------------------
731
732 /**
733 * Reset Search Atlas Authentication
734 * Clears all authentication data and tokens
735 */
736 public function reset_searchatlas_authentication()
737 {
738 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_reset_auth_nonce')) {
739 wp_send_json_error(array(
740 'message' => 'Security verification failed. Please refresh the page and try again.',
741 'code' => 'invalid_nonce'
742 ));
743 return;
744 }
745
746 if (!current_user_can('manage_options')) {
747 wp_send_json_error(array(
748 'message' => 'You do not have permission to reset authentication.',
749 'code' => 'insufficient_permissions'
750 ));
751 return;
752 }
753
754 try {
755 $options = Metasync::get_option();
756
757 if (!is_array($options)) {
758 $options = array();
759 }
760
761 if (!isset($options['general'])) {
762 $options['general'] = array();
763 }
764
765 $cleared_data = array();
766
767 if (isset($options['general']['searchatlas_api_key'])) {
768 $cleared_data['searchatlas_api_key'] = substr($options['general']['searchatlas_api_key'], 0, 8) . '...';
769 unset($options['general']['searchatlas_api_key']);
770 }
771
772 if (isset($options['general']['otto_pixel_uuid'])) {
773 $cleared_data['otto_pixel_uuid'] = $options['general']['otto_pixel_uuid'];
774 unset($options['general']['otto_pixel_uuid']);
775 }
776
777 if (isset($options['general']['send_auth_token_timestamp'])) {
778 $cleared_data['send_auth_token_timestamp'] = $options['general']['send_auth_token_timestamp'];
779 unset($options['general']['send_auth_token_timestamp']);
780 }
781
782 if (isset($options['general']['last_heart_beat'])) {
783 $cleared_data['last_heart_beat'] = $options['general']['last_heart_beat'];
784 unset($options['general']['last_heart_beat']);
785 }
786
787 # WP-426: drop the manual "Sync Now" cooldown stamp too — a stale
788 # cooldown surviving disconnect blocks the first sync after the
789 # user reconnects or saves a new API key.
790 if (isset($options['general']['last_manual_sync'])) {
791 $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
792 unset($options['general']['last_manual_sync']);
793 }
794
795 # Clear the dedicated heartbeat throttle option (WP-351 moved
796 # throttle timestamps out of the main blob).
797 delete_option(Metasync::heartbeat_throttle_option);
798 $cleared_data['heartbeat_throttle'] = 'removed';
799
800 $save_result = Metasync::set_option($options);
801
802 if (!$save_result) {
803 throw new Exception('Failed to save updated plugin options');
804 }
805
806 delete_option('metasync_wp_sa_connect_token');
807 $cleared_data['wp_sa_connect_token'] = 'removed';
808
809 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
810 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
811
812 delete_option(Metasync::option_name . '_whitelabel_user');
813 $cleared_data['whitelabel_user'] = 'removed';
814
815 if (isset($options['whitelabel'])) {
816 $cleared_data['whitelabel_settings'] = 'removed';
817 unset($options['whitelabel']);
818
819 Metasync::set_option($options);
820 }
821
822 $this->clear_jwt_token_cache();
823 $cleared_data['jwt_token_cache'] = 'cleared';
824
825 $this->cleanup_searchatlas_rate_limits();
826 $cleared_data['rate_limits'] = 'cleared';
827
828 $otto_uuid = $cleared_data['otto_pixel_uuid'] ?? '';
829 if (!empty($otto_uuid)) {
830 delete_transient(Metasync_Heartbeat_Manager::public_hash_cache_key($otto_uuid));
831 }
832 $cleared_data['public_hash_cache'] = 'cleared';
833
834 delete_transient('metasync_heartbeat_status_cache');
835 # WP-426: also drop the last-known-state fallback. With it left at
836 # `true`, the header badge flips between "Not Connected" and
837 # "Warning" across refreshes depending on whether the status cache
838 # transient is alive once an API key exists again.
839 delete_option('metasync_last_known_connection_state');
840 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
841 $cleared_data['heartbeat_cache'] = 'cleared';
842 $cleared_data['last_known_connection_state'] = 'cleared';
843
844 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
845
846 wp_send_json_success(array(
847 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
848 'cleared_data' => $cleared_data,
849 'timestamp' => current_time('mysql', true)
850 ));
851
852 } catch (Exception $e) {
853 error_log('Authentication Reset Error: ' . $e->getMessage());
854 wp_send_json_error(array(
855 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
856 'code' => 'reset_failed',
857 'error' => $e->getMessage()
858 ));
859 }
860 }
861
862 // ------------------------------------------------------------------
863 // Test / debug endpoints
864 // ------------------------------------------------------------------
865
866 /**
867 * Test the enhanced Search Atlas connect token system (development/debugging)
868 */
869 public function test_enhanced_searchatlas_tokens()
870 {
871 if (!current_user_can('manage_options')) {
872 return false;
873 }
874
875 $general_options = Metasync::get_option('general') ?? [];
876 $test_token = $general_options['apikey'] ?? null;
877
878 $apikey = $general_options['apikey'] ?? '';
879
880 $encrypted_token = $this->create_encrypted_searchatlas_token(['test' => 'data', 'user_id' => get_current_user_id()]);
881 if ($encrypted_token) {
882 $decrypted = $this->wp_decrypt_token($encrypted_token);
883 }
884
885 return true;
886 }
887
888 /**
889 * Test Search Atlas connect AJAX endpoint (development/debugging)
890 */
891 public function test_searchatlas_ajax_endpoint()
892 {
893 if (!current_user_can('manage_options')) {
894 wp_send_json_error(array(
895 'message' => 'Insufficient permissions for AJAX test',
896 'required_capability' => 'manage_options'
897 ));
898 return;
899 }
900
901 $nonce_valid = false;
902 if (isset($_POST['nonce'])) {
903 $nonce_valid = wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce');
904 }
905
906 wp_send_json_success(array(
907 'message' => 'AJAX endpoint is working correctly',
908 'timestamp' => current_time('mysql', true),
909 'user_id' => get_current_user_id(),
910 'endpoint' => 'test_searchatlas_ajax_endpoint',
911 'nonce_valid' => $nonce_valid,
912 'debug_info' => array(
913 'post_action' => isset($_POST['action']) ? sanitize_text_field(wp_unslash($_POST['action'])) : 'NOT SET',
914 'has_nonce' => isset($_POST['nonce']),
915 'user_can_manage_options' => current_user_can('manage_options')
916 )
917 ));
918 }
919
920 /**
921 * Simple AJAX test without nonce (for debugging connectivity)
922 */
923 public function simple_ajax_test()
924 {
925 wp_send_json_success(array(
926 'message' => 'Basic AJAX connectivity works',
927 'timestamp' => time(),
928 'no_nonce_required' => true
929 ));
930 }
931
932 /**
933 * Test whitelabel domain configuration (development/debugging)
934 */
935 public function test_whitelabel_domain()
936 {
937 if (!current_user_can('manage_options')) {
938 wp_send_json_error('Administrator access required');
939 return;
940 }
941
942 $whitelabel_settings = Metasync::get_whitelabel_settings();
943
944 $is_enabled = Metasync::is_whitelabel_enabled();
945
946 $effective_domain = Metasync_Admin::get_effective_dashboard_domain();
947 $metasync_domain = Metasync::get_dashboard_domain();
948
949 $whitelabel_logo = Metasync::get_whitelabel_logo();
950
951 $default_domain = Metasync::DASHBOARD_DOMAIN;
952
953 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
954
955 $effective_plugin_name = Metasync::get_effective_plugin_name('Test Plugin');
956
957 wp_send_json_success(array(
958 'whitelabel_settings' => $whitelabel_settings,
959 'is_enabled' => $is_enabled,
960 'effective_domain' => $effective_domain,
961 'whitelabel_logo' => $whitelabel_logo,
962 'whitelabel_company_name' => $whitelabel_company_name,
963 'effective_plugin_name' => $effective_plugin_name,
964 'default_domain' => $default_domain,
965 'override_active' => $effective_domain !== $default_domain
966 ));
967 }
968
969 // ------------------------------------------------------------------
970 // Whitelabel session / password management
971 // ------------------------------------------------------------------
972
973 /**
974 * Handle session management early in the admin lifecycle
975 */
976 public function handle_session_management_early()
977 {
978 if (!is_admin()) {
979 return;
980 }
981
982 $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
983 $current_page = isset($_GET['page']) ? $_GET['page'] : '';
984
985 $whitelabel_settings = Metasync::get_whitelabel_settings();
986 $user_password = $whitelabel_settings['settings_password'] ?? '';
987 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
988
989 $protected_tabs = [];
990 if (!empty($user_password)) {
991 $protected_tabs[] = 'whitelabel';
992 }
993 if ($hide_settings_enabled && !empty($user_password)) {
994 $protected_tabs = ['general', 'whitelabel', 'advanced'];
995 }
996
997 if (strpos($current_page, Metasync_Admin::$page_slug) === 0 && in_array($active_tab, $protected_tabs)) {
998 if ((defined('REST_REQUEST') && REST_REQUEST) ||
999 (defined('DOING_AJAX') && DOING_AJAX) ||
1000 (defined('DOING_CRON') && DOING_CRON)) {
1001 return;
1002 }
1003
1004 $this->handle_whitelabel_session_logic();
1005 }
1006 }
1007
1008 /**
1009 * Handle whitelabel authentication logic (login/logout/validation)
1010 * Uses Metasync_Auth_Manager instead of sessions for better compatibility
1011 */
1012 public function handle_whitelabel_session_logic()
1013 {
1014 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1015
1016 $admin_password = 'abracadabra@2020';
1017
1018 $whitelabel_settings = Metasync::get_whitelabel_settings();
1019 $user_password = $whitelabel_settings['settings_password'] ?? '';
1020
1021 $valid_passwords = array($admin_password);
1022 if (!empty($user_password)) {
1023 $valid_passwords[] = $user_password;
1024 }
1025
1026 if (isset($_POST['whitelabel_logout'])) {
1027 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1028 $auth->revoke_access();
1029
1030 $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1031 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1032 wp_redirect($redirect_url);
1033 exit;
1034 }
1035 }
1036
1037 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1038 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1039 $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1040
1041 $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1042 }
1043 }
1044 }
1045
1046 /**
1047 * Handle whitelabel password early before WordPress filters it out
1048 */
1049 public function handle_whitelabel_password_early()
1050 {
1051 if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1052
1053 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1054 $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1055
1056 $current_options = Metasync::get_option();
1057
1058 if (!isset($current_options['whitelabel'])) {
1059 $current_options['whitelabel'] = [];
1060 }
1061
1062 $current_options['whitelabel']['settings_password'] = $submitted_password;
1063 $current_options['whitelabel']['updated_at'] = time();
1064
1065 update_option(Metasync_Admin::option_key, $current_options);
1066 }
1067 }
1068 }
1069 }
1070