PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
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.22, at includes/class-metasync-connect-manager.php

1,293 lines 50.2 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 (!current_user_can('manage_options')) {
160 wp_send_json_error(array('message' => 'Insufficient permissions'));
161 return;
162 }
163
164 if (!Metasync::current_user_has_plugin_access()) {
165 wp_send_json_error(array('message' => 'Insufficient permissions'));
166 return;
167 }
168
169 try {
170 $new_plugin_auth_token = wp_generate_password(32, false, false);
171
172 $options = Metasync::get_option();
173 if (!isset($options['general'])) {
174 $options['general'] = [];
175 }
176 $options['general']['apikey'] = $new_plugin_auth_token;
177
178 $save_result = Metasync::set_option($options);
179
180 if ($save_result) {
181 Metasync::log_api_key_event('token_refresh', 'plugin_auth_token', array(
182 'new_token_prefix' => substr($new_plugin_auth_token, 0, 8) . '...',
183 'triggered_by' => 'manual_refresh_button'
184 ), 'info');
185
186 do_action('metasync_trigger_immediate_heartbeat', 'Plugin Auth Token refresh - new token generated');
187
188 wp_send_json_success(array(
189 'new_token' => $new_plugin_auth_token,
190 'message' => 'Plugin Auth Token refreshed successfully'
191 ));
192 } else {
193 wp_send_json_error(array('message' => 'Failed to save new token'));
194 }
195
196 } catch (Exception $e) {
197 error_log('Plugin Auth Token Refresh Error: ' . $e->getMessage());
198 wp_send_json_error(array('message' => 'Error generating new token'));
199 }
200 }
201
202 /**
203 * Get current Plugin Auth Token (AJAX endpoint for UI updates)
204 */
205 public function get_plugin_auth_token()
206 {
207 if (!wp_verify_nonce($_POST['nonce'], 'metasync_sa_connect_nonce')) {
208 wp_send_json_error(array('message' => 'Invalid nonce'));
209 return;
210 }
211
212 if (!Metasync::current_user_has_plugin_access()) {
213 wp_send_json_error(array('message' => 'Insufficient permissions'));
214 return;
215 }
216
217 try {
218 $options = Metasync::get_option();
219 $current_plugin_auth_token = $options['general']['apikey'] ?? '';
220
221 if (!empty($current_plugin_auth_token)) {
222 wp_send_json_success(array(
223 'plugin_auth_token' => $current_plugin_auth_token,
224 'message' => 'Plugin Auth Token retrieved successfully'
225 ));
226 } else {
227 wp_send_json_error(array('message' => 'Plugin Auth Token not found'));
228 }
229
230 } catch (Exception $e) {
231 error_log('Get Plugin Auth Token Error: ' . $e->getMessage());
232 wp_send_json_error(array('message' => 'Error retrieving Plugin Auth Token'));
233 }
234 }
235
236 // ------------------------------------------------------------------
237 // Search Atlas Connect URL & polling
238 // ------------------------------------------------------------------
239
240 /**
241 * Generate Search Atlas Connect URL (1-click connect).
242 *
243 * AJAX action: wp_ajax_metasync_generate_connect_url
244 */
245 public function generate_searchatlas_connect_url()
246 {
247 if (!current_user_can('manage_options')) {
248 wp_send_json_error(array('message' => 'Insufficient permissions. Administrator access required.'));
249 return;
250 }
251
252 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce')) {
253 wp_send_json_error(array('message' => 'Invalid nonce - please refresh the page and try again'));
254 return;
255 }
256
257 $rate_limit_key = 'metasync_sa_connect_rate_' . get_current_user_id();
258 $rate_limit_count = get_transient($rate_limit_key);
259 if ($rate_limit_count !== false && $rate_limit_count >= 10) {
260 wp_send_json_error(array('message' => 'Too many connect requests. Please wait a few minutes before trying again.'));
261 return;
262 }
263 set_transient($rate_limit_key, ($rate_limit_count === false ? 1 : $rate_limit_count + 1), 300);
264
265 try {
266 $this->ensure_plugin_auth_token_exists();
267
268 $sa_connect_token = $this->create_searchatlas_nonce_token();
269
270 if (!$sa_connect_token) {
271 wp_send_json_error(array('message' => 'Failed to create authentication token'));
272 return;
273 }
274
275 $domain = str_replace('://www.', '://', get_site_url());
276
277 $dashboard_domain = Metasync_Admin::get_effective_dashboard_domain();
278
279 $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query([
280 'nonce_token' => $sa_connect_token,
281 'domain' => $domain,
282 'callback_url' => get_rest_url(null, 'metasync/v1/searchatlas/connect/callback'),
283 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
284 ]);
285
286 wp_send_json_success(array(
287 'connect_url' => $sa_connect_url,
288 'nonce_token' => $sa_connect_token,
289 'debug_info' => array(
290 'dashboard_domain' => $dashboard_domain,
291 'site_domain' => $domain,
292 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
293 )
294 ));
295
296 } catch (Exception $e) {
297 wp_send_json_error(array('message' => 'Failed to generate Search Atlas connect URL: ' . $e->getMessage()));
298 }
299 }
300
301 /**
302 * Check Search Atlas Connect Status (polling endpoint).
303 *
304 * AJAX action: wp_ajax_metasync_check_connect_status
305 */
306 public function check_searchatlas_connect_status()
307 {
308 if (!current_user_can('manage_options')) {
309 wp_send_json_error(array('message' => 'Insufficient permissions. Administrator access required.'));
310 return;
311 }
312
313 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce')) {
314 wp_send_json_error(array('message' => 'Invalid nonce'));
315 return;
316 }
317
318 $nonce_token = isset($_POST['nonce_token']) ? sanitize_text_field(wp_unslash($_POST['nonce_token'])) : '';
319
320 // Check if THIS specific nonce was successfully processed
321 // This prevents false positives from background sync/heartbeat activity
322 $success_key = 'metasync_sa_connect_success_' . md5($nonce_token);
323 $this_auth_completed = get_transient($success_key);
324
325 // Fallback: on sites with external object cache, the success flag written
326 // by the REST callback (SA server process) is invisible to this AJAX poll
327 // (admin user process). Read directly from wp_options as fallback.
328 if (!$this_auth_completed && wp_using_ext_object_cache()) {
329 global $wpdb;
330
331 // Check expiry first
332 $timeout = $wpdb->get_var(
333 $wpdb->prepare(
334 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
335 '_transient_timeout_' . $success_key
336 )
337 );
338
339 if ($timeout && (int) $timeout < time()) {
340 // Expired — clean up
341 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
342 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
343 } else {
344 $db_value = $wpdb->get_var(
345 $wpdb->prepare(
346 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
347 '_transient_' . $success_key
348 )
349 );
350 if ($db_value) {
351 $this_auth_completed = true;
352 }
353 }
354 }
355
356 if ($this_auth_completed) {
357 // Delete the transient (one-time use) to prevent replay
358 delete_transient($success_key);
359 // Also clean up DB fallback rows
360 if (wp_using_ext_object_cache()) {
361 global $wpdb;
362 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key));
363 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key));
364 }
365
366 // Get current settings to return connection status
367 $general_settings = Metasync::get_option('general') ?? [];
368
369 // Never return the cleartext key to the browser; send a masked value only.
370 $decrypted_key = Metasync::get_searchatlas_api_key();
371 $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
372 ? str_repeat('*', 8) . substr($decrypted_key, -4)
373 : '';
374
375 wp_send_json_success(array(
376 'updated' => true,
377 'masked_key' => $masked_key, // Masked key for display only
378 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '', // Return OTTO UUID for UI update
379 'status_code' => 200,
380 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
381 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
382 ));
383 }
384
385 // WP-558 (Option A): pull-based connect fallback.
386 // The push callback (SA server -> this site) is silently dropped by
387 // Cloudflare/WAFs/basic-auth/"coming soon" plugins on many hosts, which
388 // is the #1 connect failure. Outbound requests from this site are NOT
389 // firewalled, so poll the CA endpoint for the staged result using the
390 // same nonce token we already sent to the SSO flow. The push callback
391 // above is checked first and stays intact as a fallback — pull is purely
392 // additive, and mark_searchatlas_nonce_used() is idempotent.
393 if (!empty($nonce_token) && apply_filters('metasync_enable_pull_connect', true)) {
394 $pull = $this->poll_pull_based_connect_result($nonce_token);
395
396 if (is_array($pull)) {
397 $connect = (isset($pull['connect']) && is_array($pull['connect'])) ? $pull['connect'] : array();
398 $pull_status_code = isset($connect['status_code']) ? intval($connect['status_code']) : 0;
399
400 if ($pull['status'] === 'ready'
401 && $pull_status_code === 200
402 && !empty($connect['api_key'])
403 && !empty($connect['uuid'])) {
404
405 // Persist through the EXACT same path the push callback uses.
406 // Run the pulled payload through the SAME validation +
407 // normalization the push callback applies
408 // (extract_and_validate_searchatlas_params) BEFORE storing, so
409 // pull and push share identical acceptance criteria: a stringy
410 // is_whitelabel ("false"/"0") is coerced to a real bool, and a
411 // malformed/oversized api_key or uuid is rejected instead of
412 // stored verbatim. Only after this is the storage provably
413 // identical to the push body.
414 $rest_api = new Metasync_Rest_Api('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
415 $validated = $rest_api->extract_and_validate_searchatlas_params($connect);
416
417 if (!is_wp_error($validated)) {
418 $stored = $rest_api->mark_searchatlas_nonce_used(
419 $nonce_token,
420 $validated['api_key'],
421 $validated['uuid'],
422 $validated['status_code'],
423 $validated['is_whitelabel'],
424 $validated['whitelabel_domain'],
425 $validated['whitelabel_logo'],
426 $validated['whitelabel_company_name'],
427 $validated['whitelabel_otto']
428 );
429
430 if ($stored) {
431 // mark_searchatlas_nonce_used() sets the one-time success
432 // transient keyed by md5($nonce_token); clear it since we
433 // report success directly here (avoids a stale replay flag).
434 delete_transient('metasync_sa_connect_success_' . md5($nonce_token));
435
436 $general_settings = Metasync::get_option('general') ?? [];
437
438 // Never return the cleartext key to the browser.
439 $decrypted_key = Metasync::get_searchatlas_api_key();
440 $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
441 ? str_repeat('*', 8) . substr($decrypted_key, -4)
442 : '';
443
444 wp_send_json_success(array(
445 'updated' => true,
446 'masked_key' => $masked_key,
447 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '',
448 'status_code' => 200,
449 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
450 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
451 'source' => 'pull',
452 ));
453 }
454 }
455 // A WP_Error from validation (malformed staged payload) is not
456 // persisted; we fall through to updated:false so polling
457 // continues and the push callback / heartbeat self-heal remain.
458 } elseif ($pull['status'] === 'error') {
459 // Honest state: surface the real server error (e.g. "Domain not
460 // found for customer") instead of the silent 60s timeout. The
461 // existing JS branches on status_code (404/500/…) to render it.
462 wp_send_json_success(array(
463 'updated' => true,
464 'status_code' => $pull_status_code ?: 400,
465 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
466 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
467 'source' => 'pull',
468 ));
469 }
470 }
471 }
472
473 wp_send_json_success(array('updated' => false));
474 }
475
476 /**
477 * WP-558 (Option A): poll the CA pull-based connect-result endpoint.
478 *
479 * Outbound GET to `<CA base>/api/wp-plugin-connect-result/?url=<site_url>` with
480 * the same SSO nonce sent as the `X-Plugin-Token` header. `url` uses the exact
481 * expression the SSO flow registered (str_replace('://www.','://', get_site_url()))
482 * so it matches the server's `(hostname, sha256(nonce))` key. Outbound traffic
483 * from the site bypasses the Cloudflare/WAF/basic-auth layers that silently drop
484 * the inbound push callback.
485 *
486 * @param string $nonce_token The SSO connect nonce (same value sent to the dashboard).
487 * @return array|null ['status' => 'ready'|'error', 'connect' => array] when the
488 * server has a staged result; null to keep polling (pending,
489 * throttled, bad request, or transport failure — the push
490 * callback remains a fallback in every null case).
491 */
492 private function poll_pull_based_connect_result($nonce_token)
493 {
494 // Server rejects tokens over 512 chars with a 400; don't waste the request.
495 if (empty($nonce_token) || strlen($nonce_token) > 512) {
496 return null;
497 }
498
499 $base = class_exists('Metasync_Endpoint_Manager')
500 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
501 : (class_exists('Metasync') ? Metasync::CA_API_DOMAIN : 'https://ca.searchatlas.com');
502
503 // Same base + token convention as the activation announce ping
504 // (Metasync_Activator::send_announce_ping). Send the EXACT same domain
505 // expression the SSO flow registered with CA — generate_searchatlas_connect_url()
506 // uses str_replace('://www.', '://', get_site_url()) — so the server's
507 // (hostname, sha256(nonce)) lookup matches byte-for-byte instead of relying
508 // on server-side www normalization or home_url == site_url.
509 $domain = str_replace('://www.', '://', get_site_url());
510 $url = rtrim($base, '/') . '/api/wp-plugin-connect-result/?url=' . rawurlencode($domain);
511
512 // Timeout kept below the JS poll cadence (5s) so a slow/unreachable CA
513 // can't cause overlapping admin-ajax requests to stack up and pin PHP
514 // workers for the whole connect window.
515 $response = wp_remote_get($url, array(
516 'timeout' => 4,
517 'headers' => array(
518 'X-Plugin-Token' => $nonce_token,
519 'Accept' => 'application/json',
520 ),
521 ));
522
523 if (is_wp_error($response)) {
524 return null; // transient network failure — keep polling
525 }
526
527 // 400 (bad request) / 429 (throttled) / anything non-200 → keep polling
528 // this round; the push callback and heartbeat self-heal remain in play.
529 if ((int) wp_remote_retrieve_response_code($response) !== 200) {
530 return null;
531 }
532
533 $data = json_decode(wp_remote_retrieve_body($response), true);
534 if (!is_array($data) || empty($data['status'])) {
535 return null;
536 }
537
538 // 'ready' | 'error' are actionable; 'pending' (or anything else) keeps polling.
539 if ($data['status'] === 'ready' || $data['status'] === 'error') {
540 return array(
541 'status' => $data['status'],
542 'connect' => (isset($data['connect']) && is_array($data['connect'])) ? $data['connect'] : array(),
543 );
544 }
545
546 return null;
547 }
548
549 // ------------------------------------------------------------------
550 // Nonce / encrypted token helpers
551 // ------------------------------------------------------------------
552
553 /**
554 * Create Search Atlas Connect Nonce Token.
555 *
556 * Generates a unique, time-limited (15 min), single-use nonce token used to
557 * identify the connect session when Search Atlas calls back with the API key
558 * and Otto UUID.
559 */
560 public function create_searchatlas_nonce_token()
561 {
562 $general_options = Metasync::get_option('general') ?? [];
563 $plugin_auth_token = $general_options['apikey'] ?? '';
564
565 if (empty($plugin_auth_token)) {
566 error_log('MetaSync ERROR: Plugin Auth Token missing from options');
567 return false;
568 }
569
570 $random_bytes = wp_generate_password(32, false, false);
571 $timestamp = time();
572 $user_id = get_current_user_id();
573
574 $token_data = $random_bytes . '|' . $timestamp . '|' . $user_id . '|' . get_site_url();
575 $sa_connect_token = hash_hmac('sha256', $token_data, $plugin_auth_token . wp_salt('auth'));
576
577 $token_metadata = array(
578 'created' => $timestamp,
579 'expires' => $timestamp + 900,
580 'user_id' => $user_id,
581 'site_url' => get_site_url(),
582 'ip' => $this->get_client_ip(),
583 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 0, 100) : '',
584 'used' => false,
585 'callback_used' => false,
586 'version' => '3.0'
587 );
588
589 $transient_key = 'metasync_sa_connect_token_' . substr(hash('sha256', $sa_connect_token), 0, 32);
590 set_transient($transient_key, $token_metadata, 900);
591
592 set_transient('metasync_sa_connect_active_' . $sa_connect_token, $transient_key, 900);
593
594 // When external object cache (Redis/Memcached/LiteSpeed) is active,
595 // set_transient() writes to cache ONLY — never to wp_options. The REST
596 // callback from SA servers runs in a different process/cache context and
597 // cannot see the cached value. Write directly to DB as a fallback.
598 if (wp_using_ext_object_cache()) {
599 global $wpdb;
600 $active_option = '_transient_metasync_sa_connect_active_' . $sa_connect_token;
601 $metadata_option = '_transient_' . $transient_key;
602 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
603 $timeout_metadata = '_transient_timeout_' . $transient_key;
604 $expires = time() + 900;
605
606 $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no'));
607 $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no'));
608 $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no'));
609 $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no'));
610 }
611
612 return $sa_connect_token;
613 }
614
615 /**
616 * Get client IP address securely
617 */
618 public function get_client_ip()
619 {
620 $ip_headers = array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR');
621
622 foreach ($ip_headers as $header) {
623 if (!empty($_SERVER[$header])) {
624 $ip = $_SERVER[$header];
625 if (strpos($ip, ',') !== false) {
626 $ip = trim(explode(',', $ip)[0]);
627 }
628 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
629 return $ip;
630 }
631 }
632 }
633
634 return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
635 }
636
637 /**
638 * Create encrypted Search Atlas connect token with embedded metadata
639 */
640 public function create_encrypted_searchatlas_token($metadata = array())
641 {
642 $payload = array_merge(array(
643 'iat' => time(),
644 'exp' => time() + 1800,
645 'iss' => get_site_url(),
646 'aud' => 'search-atlas-connect',
647 'sub' => 'searchatlas-authentication',
648 'jti' => wp_generate_password(16, false),
649 'nonce' => wp_generate_password(16, false),
650 'version' => '2.0'
651 ), $metadata);
652
653 return $this->wp_encrypt_token($payload);
654 }
655
656 /**
657 * Encrypt token using WordPress SALTs
658 */
659 public function wp_encrypt_token($payload)
660 {
661 try {
662 $serialized = serialize($payload);
663
664 $key_material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
665 $encryption_key = hash('sha256', $key_material, true);
666
667 $iv = random_bytes(16);
668
669 $encrypted = openssl_encrypt($serialized, 'AES-256-CBC', $encryption_key, OPENSSL_RAW_DATA, $iv);
670
671 if ($encrypted === false) {
672 throw new Exception('Encryption failed');
673 }
674
675 $result = $iv . $encrypted;
676
677 return base64_encode($result);
678
679 } catch (Exception $e) {
680 return false;
681 }
682 }
683
684 /**
685 * Decrypt token using WordPress SALTs
686 */
687 public function wp_decrypt_token($encrypted_token)
688 {
689 try {
690 $data = base64_decode($encrypted_token, true);
691
692 if ($data === false || strlen($data) < 16) {
693 return false;
694 }
695
696 $iv = substr($data, 0, 16);
697 $encrypted = substr($data, 16);
698
699 $key_material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
700 $encryption_key = hash('sha256', $key_material, true);
701
702 $serialized = openssl_decrypt($encrypted, 'AES-256-CBC', $encryption_key, OPENSSL_RAW_DATA, $iv);
703
704 if ($serialized === false) {
705 return false;
706 }
707
708 $payload = unserialize($serialized, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
709
710 if (!is_array($payload) || !isset($payload['exp'], $payload['iat'])) {
711 return false;
712 }
713
714 if ($payload['exp'] < time()) {
715 return false;
716 }
717
718 return $payload;
719
720 } catch (Exception $e) {
721 return false;
722 }
723 }
724
725 // ------------------------------------------------------------------
726 // Cleanup helpers
727 // ------------------------------------------------------------------
728
729 /**
730 * @deprecated No longer needed with simplified token system
731 */
732 public function cleanup_searchatlas_nonce_tokens()
733 {
734 return 0;
735 }
736
737 /**
738 * Cleanup Search Atlas connect rate limiting data
739 */
740 public function cleanup_searchatlas_rate_limits()
741 {
742 global $wpdb;
743
744 try {
745 $rate_limit_transients = $wpdb->get_results(
746 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
747 ARRAY_A
748 );
749
750 $cleaned_count = 0;
751
752 foreach ($rate_limit_transients as $transient) {
753 $transient_name = str_replace('_transient_', '', $transient['option_name']);
754 delete_transient($transient_name);
755 $cleaned_count++;
756 }
757
758 return $cleaned_count;
759
760 } catch (Exception $e) {
761 return 0;
762 }
763 }
764
765 /**
766 * Clear cached JWT tokens
767 */
768 public function clear_jwt_token_cache()
769 {
770 global $wpdb;
771
772 $deleted = $wpdb->query(
773 $wpdb->prepare(
774 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
775 '_transient_metasync_jwt_token_%'
776 )
777 );
778
779 $wpdb->query(
780 $wpdb->prepare(
781 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
782 '_transient_timeout_metasync_jwt_token_%'
783 )
784 );
785 }
786
787 // ------------------------------------------------------------------
788 // JWT token management
789 // ------------------------------------------------------------------
790
791 /**
792 * Get active JWT token for the plugin.
793 * Public static method accessible from anywhere in the plugin.
794 *
795 * @param bool $force_refresh Force generation of new token even if cached one exists
796 * @return string|false JWT token on success, false on failure
797 */
798 public static function get_active_jwt_token($force_refresh = false)
799 {
800 $api_key = Metasync::get_searchatlas_api_key();
801 if ($api_key === false) {
802 $api_key = '';
803 }
804
805 if (empty($api_key)) {
806 return false;
807 }
808
809 if (!$force_refresh) {
810 $cache_key = 'metasync_jwt_token_' . md5($api_key);
811 $cached_token_data = get_transient($cache_key);
812
813 if ($cached_token_data && is_array($cached_token_data)) {
814 $expires_with_buffer = $cached_token_data['expires'] - 300;
815 if (time() < $expires_with_buffer && !empty($cached_token_data['token'])) {
816 return $cached_token_data['token'];
817 }
818 }
819 }
820
821 return self::instance()->get_fresh_jwt_token();
822 }
823
824 /**
825 * Return the cached JWT token, or false — never fetches a fresh one.
826 *
827 * get_active_jwt_token() falls through to get_fresh_jwt_token() on a cache
828 * miss, which performs a blocking POST with a 15 second timeout. That is fine
829 * for admin and cron work but not for anything on a visitor's page render,
830 * where it would add up to 15 seconds to the response.
831 *
832 * Callers on a request-path should use this and simply skip whatever they
833 * wanted the token for when it returns false. The next admin or cron request
834 * will repopulate the cache.
835 *
836 * @return string|false Cached token, or false when none is cached or it is
837 * within the expiry buffer.
838 */
839 public static function get_cached_jwt_token()
840 {
841 $api_key = Metasync::get_searchatlas_api_key();
842 if ($api_key === false) {
843 $api_key = '';
844 }
845
846 if (empty($api_key)) {
847 return false;
848 }
849
850 $cached_token_data = get_transient('metasync_jwt_token_' . md5($api_key));
851
852 if (!$cached_token_data || !is_array($cached_token_data)) {
853 return false;
854 }
855
856 # Same 5-minute expiry buffer get_active_jwt_token() applies.
857 $expires_with_buffer = $cached_token_data['expires'] - 300;
858 if (time() >= $expires_with_buffer || empty($cached_token_data['token'])) {
859 return false;
860 }
861
862 return $cached_token_data['token'];
863 }
864
865 /**
866 * Get fresh JWT token from Search Atlas API with caching
867 *
868 * @return string|false JWT token on success, false on failure
869 */
870 public function get_fresh_jwt_token()
871 {
872 $api_key = Metasync::get_searchatlas_api_key();
873 if ($api_key === false) {
874 $api_key = '';
875 }
876
877 if (empty($api_key)) {
878 return false;
879 }
880
881 $cache_key = 'metasync_jwt_token_' . md5($api_key);
882 $cached_token_data = get_transient($cache_key);
883
884 if ($cached_token_data && is_array($cached_token_data)) {
885 $expires_with_buffer = $cached_token_data['expires'] - 300;
886 if (time() < $expires_with_buffer && !empty($cached_token_data['token'])) {
887 return $cached_token_data['token'];
888 }
889 }
890
891 $api_domain = class_exists('Metasync_Endpoint_Manager')
892 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
893 : Metasync::API_DOMAIN;
894 $url = $api_domain . '/api/customer/account/generate-jwt-from-api-key/';
895
896 $args = array(
897 'method' => 'POST',
898 'headers' => array(
899 'X-API-KEY' => $api_key,
900 'Content-Type' => 'application/json'
901 ),
902 'timeout' => 15
903 );
904
905 try {
906 $response = wp_remote_post($url, $args);
907
908 if (is_wp_error($response)) {
909 error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message());
910 return false;
911 }
912
913 $response_code = wp_remote_retrieve_response_code($response);
914 $response_body = wp_remote_retrieve_body($response);
915
916 if ($response_code !== 200) {
917 error_log('MetaSync: JWT token API returned error code ' . $response_code);
918 return false;
919 }
920
921 $data = json_decode($response_body, true);
922
923 if (!$data || !isset($data['token'], $data['expires'])) {
924 error_log('MetaSync: Invalid JWT token API response format');
925 return false;
926 }
927
928 $token_data = array(
929 'token' => $data['token'],
930 'expires' => $data['expires'],
931 'created_at' => time()
932 );
933
934 $cache_duration = min($data['expires'] - time(), 24 * 3600);
935 set_transient($cache_key, $token_data, $cache_duration);
936
937 return $data['token'];
938
939 } catch (Exception $e) {
940 error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage());
941 return false;
942 }
943 }
944
945 // ------------------------------------------------------------------
946 // Authentication reset
947 // ------------------------------------------------------------------
948
949 /**
950 * Reset Search Atlas Authentication
951 * Clears all authentication data and tokens
952 */
953 public function reset_searchatlas_authentication()
954 {
955 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_reset_auth_nonce')) {
956 wp_send_json_error(array(
957 'message' => 'Security verification failed. Please refresh the page and try again.',
958 'code' => 'invalid_nonce'
959 ));
960 return;
961 }
962
963 if (!current_user_can('manage_options')) {
964 wp_send_json_error(array(
965 'message' => 'You do not have permission to reset authentication.',
966 'code' => 'insufficient_permissions'
967 ));
968 return;
969 }
970
971 try {
972 $options = Metasync::get_option();
973
974 if (!is_array($options)) {
975 $options = array();
976 }
977
978 if (!isset($options['general'])) {
979 $options['general'] = array();
980 }
981
982 $cleared_data = array();
983
984 if (isset($options['general']['searchatlas_api_key'])) {
985 // Record only a masked marker (last 4 chars) — never the stored
986 // ciphertext or the cleartext key.
987 $disconnect_key = Metasync::get_searchatlas_api_key();
988 $cleared_data['searchatlas_api_key'] = (is_string($disconnect_key) && $disconnect_key !== '')
989 ? str_repeat('', 4) . substr($disconnect_key, -4)
990 : '(removed)';
991 unset($options['general']['searchatlas_api_key']);
992 }
993
994 if (isset($options['general']['otto_pixel_uuid'])) {
995 $cleared_data['otto_pixel_uuid'] = $options['general']['otto_pixel_uuid'];
996 unset($options['general']['otto_pixel_uuid']);
997 }
998
999 if (isset($options['general']['send_auth_token_timestamp'])) {
1000 $cleared_data['send_auth_token_timestamp'] = $options['general']['send_auth_token_timestamp'];
1001 unset($options['general']['send_auth_token_timestamp']);
1002 }
1003
1004 if (isset($options['general']['last_heart_beat'])) {
1005 $cleared_data['last_heart_beat'] = $options['general']['last_heart_beat'];
1006 unset($options['general']['last_heart_beat']);
1007 }
1008
1009 # drop the manual "Sync Now" cooldown stamp too — a stale
1010 # cooldown surviving disconnect blocks the first sync after the
1011 # user reconnects or saves a new API key.
1012 if (isset($options['general']['last_manual_sync'])) {
1013 $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
1014 unset($options['general']['last_manual_sync']);
1015 }
1016
1017 # Clear the dedicated heartbeat throttle option (moved
1018 # throttle timestamps out of the main blob).
1019 delete_option(Metasync::heartbeat_throttle_option);
1020 $cleared_data['heartbeat_throttle'] = 'removed';
1021
1022 $save_result = Metasync::set_option($options);
1023 Metasync::invalidate_api_key_cache();
1024
1025 if (!$save_result) {
1026 throw new Exception('Failed to save updated plugin options');
1027 }
1028
1029 delete_option('metasync_wp_sa_connect_token');
1030 $cleared_data['wp_sa_connect_token'] = 'removed';
1031
1032 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
1033 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
1034
1035 delete_option(Metasync::option_name . '_whitelabel_user');
1036 $cleared_data['whitelabel_user'] = 'removed';
1037
1038 if (isset($options['whitelabel'])) {
1039 $cleared_data['whitelabel_settings'] = 'removed';
1040 unset($options['whitelabel']);
1041
1042 Metasync::set_option($options);
1043 }
1044
1045 $this->clear_jwt_token_cache();
1046 $cleared_data['jwt_token_cache'] = 'cleared';
1047
1048 $this->cleanup_searchatlas_rate_limits();
1049 $cleared_data['rate_limits'] = 'cleared';
1050
1051 $otto_uuid = $cleared_data['otto_pixel_uuid'] ?? '';
1052 if (!empty($otto_uuid)) {
1053 delete_transient(Metasync_Heartbeat_Manager::public_hash_cache_key($otto_uuid));
1054 }
1055 $cleared_data['public_hash_cache'] = 'cleared';
1056
1057 delete_transient('metasync_heartbeat_status_cache');
1058 # also drop the last-known-state fallback. With it left at
1059 # `true`, the header badge flips between "Not Connected" and
1060 # "Warning" across refreshes depending on whether the status cache
1061 # transient is alive once an API key exists again.
1062 delete_option('metasync_last_known_connection_state');
1063 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1064 $cleared_data['heartbeat_cache'] = 'cleared';
1065 $cleared_data['last_known_connection_state'] = 'cleared';
1066
1067 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
1068
1069 wp_send_json_success(array(
1070 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
1071 'cleared_data' => $cleared_data,
1072 'timestamp' => current_time('mysql', true)
1073 ));
1074
1075 } catch (Exception $e) {
1076 error_log('Authentication Reset Error: ' . $e->getMessage());
1077 wp_send_json_error(array(
1078 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
1079 'code' => 'reset_failed',
1080 'error' => $e->getMessage()
1081 ));
1082 }
1083 }
1084
1085 // ------------------------------------------------------------------
1086 // Test / debug endpoints
1087 // ------------------------------------------------------------------
1088
1089 /**
1090 * Test the enhanced Search Atlas connect token system (development/debugging)
1091 */
1092 public function test_enhanced_searchatlas_tokens()
1093 {
1094 if (!current_user_can('manage_options')) {
1095 return false;
1096 }
1097
1098 $general_options = Metasync::get_option('general') ?? [];
1099 $test_token = $general_options['apikey'] ?? null;
1100
1101 $apikey = $general_options['apikey'] ?? '';
1102
1103 $encrypted_token = $this->create_encrypted_searchatlas_token(['test' => 'data', 'user_id' => get_current_user_id()]);
1104 if ($encrypted_token) {
1105 $decrypted = $this->wp_decrypt_token($encrypted_token);
1106 }
1107
1108 return true;
1109 }
1110
1111 /**
1112 * Test Search Atlas connect AJAX endpoint (development/debugging)
1113 */
1114 public function test_searchatlas_ajax_endpoint()
1115 {
1116 if (!current_user_can('manage_options')) {
1117 wp_send_json_error(array(
1118 'message' => 'Insufficient permissions for AJAX test',
1119 'required_capability' => 'manage_options'
1120 ));
1121 return;
1122 }
1123
1124 $nonce_valid = false;
1125 if (isset($_POST['nonce'])) {
1126 $nonce_valid = wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce');
1127 }
1128
1129 wp_send_json_success(array(
1130 'message' => 'AJAX endpoint is working correctly',
1131 'timestamp' => current_time('mysql', true),
1132 'user_id' => get_current_user_id(),
1133 'endpoint' => 'test_searchatlas_ajax_endpoint',
1134 'nonce_valid' => $nonce_valid,
1135 'debug_info' => array(
1136 'post_action' => isset($_POST['action']) ? sanitize_text_field(wp_unslash($_POST['action'])) : 'NOT SET',
1137 'has_nonce' => isset($_POST['nonce']),
1138 'user_can_manage_options' => current_user_can('manage_options')
1139 )
1140 ));
1141 }
1142
1143 /**
1144 * Simple AJAX test without nonce (for debugging connectivity)
1145 */
1146 public function simple_ajax_test()
1147 {
1148 wp_send_json_success(array(
1149 'message' => 'Basic AJAX connectivity works',
1150 'timestamp' => time(),
1151 'no_nonce_required' => true
1152 ));
1153 }
1154
1155 /**
1156 * Test whitelabel domain configuration (development/debugging)
1157 */
1158 public function test_whitelabel_domain()
1159 {
1160 if (!current_user_can('manage_options')) {
1161 wp_send_json_error('Administrator access required');
1162 return;
1163 }
1164
1165 $whitelabel_settings = Metasync::get_whitelabel_settings();
1166
1167 $is_enabled = Metasync::is_whitelabel_enabled();
1168
1169 $effective_domain = Metasync_Admin::get_effective_dashboard_domain();
1170 $metasync_domain = Metasync::get_dashboard_domain();
1171
1172 $whitelabel_logo = Metasync::get_whitelabel_logo();
1173
1174 $default_domain = Metasync::DASHBOARD_DOMAIN;
1175
1176 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
1177
1178 $effective_plugin_name = Metasync::get_effective_plugin_name('Test Plugin');
1179
1180 wp_send_json_success(array(
1181 'whitelabel_settings' => $whitelabel_settings,
1182 'is_enabled' => $is_enabled,
1183 'effective_domain' => $effective_domain,
1184 'whitelabel_logo' => $whitelabel_logo,
1185 'whitelabel_company_name' => $whitelabel_company_name,
1186 'effective_plugin_name' => $effective_plugin_name,
1187 'default_domain' => $default_domain,
1188 'override_active' => $effective_domain !== $default_domain
1189 ));
1190 }
1191
1192 // ------------------------------------------------------------------
1193 // Whitelabel session / password management
1194 // ------------------------------------------------------------------
1195
1196 /**
1197 * Handle session management early in the admin lifecycle
1198 */
1199 public function handle_session_management_early()
1200 {
1201 if (!is_admin()) {
1202 return;
1203 }
1204
1205 $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
1206 $current_page = isset($_GET['page']) ? $_GET['page'] : '';
1207
1208 $whitelabel_settings = Metasync::get_whitelabel_settings();
1209 $user_password = $whitelabel_settings['settings_password'] ?? '';
1210 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
1211
1212 $protected_tabs = [];
1213 if (!empty($user_password)) {
1214 $protected_tabs[] = 'whitelabel';
1215 }
1216 if ($hide_settings_enabled && !empty($user_password)) {
1217 $protected_tabs = ['general', 'whitelabel', 'advanced'];
1218 }
1219
1220 if (strpos($current_page, Metasync_Admin::$page_slug) === 0 && in_array($active_tab, $protected_tabs)) {
1221 if ((defined('REST_REQUEST') && REST_REQUEST) ||
1222 (defined('DOING_AJAX') && DOING_AJAX) ||
1223 (defined('DOING_CRON') && DOING_CRON)) {
1224 return;
1225 }
1226
1227 $this->handle_whitelabel_session_logic();
1228 }
1229 }
1230
1231 /**
1232 * Handle whitelabel authentication logic (login/logout/validation)
1233 * Uses Metasync_Auth_Manager instead of sessions for better compatibility
1234 */
1235 public function handle_whitelabel_session_logic()
1236 {
1237 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1238
1239 $admin_password = 'abracadabra@2020';
1240
1241 // Decrypted plaintext for verification; '' when unset or undecryptable.
1242 $user_password = Metasync::get_whitelabel_password();
1243
1244 $valid_passwords = array($admin_password);
1245 if (!empty($user_password)) {
1246 $valid_passwords[] = $user_password;
1247 }
1248
1249 if (isset($_POST['whitelabel_logout'])) {
1250 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1251 $auth->revoke_access();
1252
1253 $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1254 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1255 wp_redirect($redirect_url);
1256 exit;
1257 }
1258 }
1259
1260 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1261 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1262 $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1263
1264 $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1265 }
1266 }
1267 }
1268
1269 /**
1270 * Handle whitelabel password early before WordPress filters it out
1271 */
1272 public function handle_whitelabel_password_early()
1273 {
1274 if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1275
1276 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1277 $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1278
1279 $current_options = Metasync::get_option();
1280
1281 if (!isset($current_options['whitelabel'])) {
1282 $current_options['whitelabel'] = [];
1283 }
1284
1285 $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1286 $current_options['whitelabel']['updated_at'] = time();
1287
1288 update_option(Metasync_Admin::option_key, $current_options);
1289 }
1290 }
1291 }
1292 }
1293