PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 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 All 141 releases
metasync / includes / class-metasync-connect-manager.php

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

1,405 lines 59.7 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'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
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()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
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()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
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 $connect_args = [
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 if (Metasync_Headless_Config::is_active()) {
282 $connect_args['frontend_domain'] = Metasync_Headless_Config::get_frontend_domain();
283 }
284
285 $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query($connect_args);
286
287 // Consent-gated analytics: the denominator for searchatlas_connected.
288 $existing_key = Metasync::get_searchatlas_api_key();
289 Metasync_PostHog::feature('searchatlas_connect_started', array(
290 'is_reconnection' => is_string($existing_key) && $existing_key !== '',
291 'is_whitelabel' => (bool) Metasync::is_whitelabel_enabled(),
292 ));
293
294 wp_send_json_success(array(
295 'connect_url' => $sa_connect_url,
296 'nonce_token' => $sa_connect_token,
297 'debug_info' => array(
298 'dashboard_domain' => $dashboard_domain,
299 'site_domain' => $domain,
300 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
301 )
302 ));
303
304 } catch (Exception $e) {
305 wp_send_json_error(array('message' => 'Failed to generate Search Atlas connect URL: ' . $e->getMessage()));
306 }
307 }
308
309 /**
310 * Check Search Atlas Connect Status (polling endpoint).
311 *
312 * AJAX action: wp_ajax_metasync_check_connect_status
313 */
314 public function check_searchatlas_connect_status()
315 {
316 if (!current_user_can('manage_options')) {
317 wp_send_json_error(array('message' => 'Insufficient permissions. Administrator access required.'));
318 return;
319 }
320
321 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce')) {
322 wp_send_json_error(array('message' => 'Invalid nonce'));
323 return;
324 }
325
326 $nonce_token = isset($_POST['nonce_token']) ? sanitize_text_field(wp_unslash($_POST['nonce_token'])) : '';
327
328 // Check if THIS specific nonce was successfully processed
329 // This prevents false positives from background sync/heartbeat activity
330 $success_key = 'metasync_sa_connect_success_' . md5($nonce_token);
331 $this_auth_completed = get_transient($success_key);
332
333 // Fallback: on sites with external object cache, the success flag written
334 // by the REST callback (SA server process) is invisible to this AJAX poll
335 // (admin user process). Read directly from wp_options as fallback.
336 if (!$this_auth_completed && wp_using_ext_object_cache()) {
337 global $wpdb;
338
339 // Check expiry first
340 $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
341 $wpdb->prepare(
342 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
343 '_transient_timeout_' . $success_key
344 )
345 );
346
347 if ($timeout && (int) $timeout < time()) {
348 // Expired — clean up
349 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
350 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
351 } else {
352 $db_value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
353 $wpdb->prepare(
354 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
355 '_transient_' . $success_key
356 )
357 );
358 if ($db_value) {
359 $this_auth_completed = true;
360 }
361 }
362 }
363
364 if ($this_auth_completed) {
365 // Delete the transient (one-time use) to prevent replay
366 delete_transient($success_key);
367 // Also clean up DB fallback rows
368 if (wp_using_ext_object_cache()) {
369 global $wpdb;
370 $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
371 $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
372 }
373
374 // Get current settings to return connection status
375 $general_settings = Metasync::get_option('general') ?? [];
376
377 // Never return the cleartext key to the browser; send a masked value only.
378 $decrypted_key = Metasync::get_searchatlas_api_key();
379 $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
380 ? str_repeat('*', 8) . substr($decrypted_key, -4)
381 : '';
382
383 wp_send_json_success(array(
384 'updated' => true,
385 'masked_key' => $masked_key, // Masked key for display only
386 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '', // Return OTTO UUID for UI update
387 'status_code' => 200,
388 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
389 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
390 ));
391 }
392
393 // WP-558 (Option A): pull-based connect fallback.
394 // The push callback (SA server -> this site) is silently dropped by
395 // Cloudflare/WAFs/basic-auth/"coming soon" plugins on many hosts, which
396 // is the #1 connect failure. Outbound requests from this site are NOT
397 // firewalled, so poll the CA endpoint for the staged result using the
398 // same nonce token we already sent to the SSO flow. The push callback
399 // above is checked first and stays intact as a fallback — pull is purely
400 // additive, and mark_searchatlas_nonce_used() is idempotent.
401 if (!empty($nonce_token) && apply_filters('metasync_enable_pull_connect', true)) {
402 $pull = $this->poll_pull_based_connect_result($nonce_token);
403
404 if (is_array($pull)) {
405 $connect = (isset($pull['connect']) && is_array($pull['connect'])) ? $pull['connect'] : array();
406 $pull_status_code = isset($connect['status_code']) ? intval($connect['status_code']) : 0;
407
408 if ($pull['status'] === 'ready'
409 && $pull_status_code === 200
410 && !empty($connect['api_key'])
411 && !empty($connect['uuid'])) {
412
413 // Persist through the EXACT same path the push callback uses.
414 // Run the pulled payload through the SAME validation +
415 // normalization the push callback applies
416 // (extract_and_validate_searchatlas_params) BEFORE storing, so
417 // pull and push share identical acceptance criteria: a stringy
418 // is_whitelabel ("false"/"0") is coerced to a real bool, and a
419 // malformed/oversized api_key or uuid is rejected instead of
420 // stored verbatim. Only after this is the storage provably
421 // identical to the push body.
422 $rest_api = new Metasync_Rest_Api('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
423 $validated = $rest_api->extract_and_validate_searchatlas_params($connect);
424
425 if (!is_wp_error($validated)) {
426 $stored = $rest_api->mark_searchatlas_nonce_used(
427 $nonce_token,
428 $validated['api_key'],
429 $validated['uuid'],
430 $validated['status_code'],
431 $validated['is_whitelabel'],
432 $validated['whitelabel_domain'],
433 $validated['whitelabel_logo'],
434 $validated['whitelabel_company_name'],
435 $validated['whitelabel_otto']
436 );
437
438 if ($stored) {
439 // mark_searchatlas_nonce_used() sets the one-time success
440 // transient keyed by md5($nonce_token); clear it since we
441 // report success directly here (avoids a stale replay flag).
442 delete_transient('metasync_sa_connect_success_' . md5($nonce_token));
443
444 $general_settings = Metasync::get_option('general') ?? [];
445
446 // Never return the cleartext key to the browser.
447 $decrypted_key = Metasync::get_searchatlas_api_key();
448 $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
449 ? str_repeat('*', 8) . substr($decrypted_key, -4)
450 : '';
451
452 wp_send_json_success(array(
453 'updated' => true,
454 'masked_key' => $masked_key,
455 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '',
456 'status_code' => 200,
457 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
458 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
459 'source' => 'pull',
460 ));
461 }
462 }
463 // A WP_Error from validation (malformed staged payload) is not
464 // persisted; we fall through to updated:false so polling
465 // continues and the push callback / heartbeat self-heal remain.
466 } elseif ($pull['status'] === 'error') {
467 // Honest state: surface the real server error (e.g. "Domain not
468 // found for customer") instead of the silent 60s timeout. The
469 // existing JS branches on status_code (404/500/…) to render it.
470 // Once per connect attempt, even if the poll repeats.
471 Metasync_PostHog::feature_once('searchatlas_connect_failed', array(
472 'status_code' => $pull_status_code ?: 400,
473 'source' => 'pull',
474 ), md5($nonce_token), 15 * MINUTE_IN_SECONDS);
475 wp_send_json_success(array(
476 'updated' => true,
477 'status_code' => $pull_status_code ?: 400,
478 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
479 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
480 'source' => 'pull',
481 ));
482 }
483 }
484 }
485
486 wp_send_json_success(array('updated' => false));
487 }
488
489 /**
490 * WP-558 (Option A): poll the CA pull-based connect-result endpoint.
491 *
492 * Outbound GET to `<CA base>/api/wp-plugin-connect-result/?url=<site_url>` with
493 * the same SSO nonce sent as the `X-Plugin-Token` header. `url` uses the exact
494 * expression the SSO flow registered (str_replace('://www.','://', get_site_url()))
495 * so it matches the server's `(hostname, sha256(nonce))` key. Outbound traffic
496 * from the site bypasses the Cloudflare/WAF/basic-auth layers that silently drop
497 * the inbound push callback.
498 *
499 * @param string $nonce_token The SSO connect nonce (same value sent to the dashboard).
500 * @return array|null ['status' => 'ready'|'error', 'connect' => array] when the
501 * server has a staged result; null to keep polling (pending,
502 * throttled, bad request, or transport failure — the push
503 * callback remains a fallback in every null case).
504 */
505 private function poll_pull_based_connect_result($nonce_token)
506 {
507 // Server rejects tokens over 512 chars with a 400; don't waste the request.
508 if (empty($nonce_token) || strlen($nonce_token) > 512) {
509 return null;
510 }
511
512 $base = class_exists('Metasync_Endpoint_Manager')
513 ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
514 : (class_exists('Metasync') ? Metasync::CA_API_DOMAIN : 'https://ca.searchatlas.com');
515
516 // Same base + token convention as the activation announce ping
517 // (Metasync_Activator::send_announce_ping). Send the EXACT same domain
518 // expression the SSO flow registered with CA — generate_searchatlas_connect_url()
519 // uses str_replace('://www.', '://', get_site_url()) — so the server's
520 // (hostname, sha256(nonce)) lookup matches byte-for-byte instead of relying
521 // on server-side www normalization or home_url == site_url.
522 $domain = str_replace('://www.', '://', get_site_url());
523 $url = rtrim($base, '/') . '/api/wp-plugin-connect-result/?url=' . rawurlencode($domain);
524
525 // Timeout kept below the JS poll cadence (5s) so a slow/unreachable CA
526 // can't cause overlapping admin-ajax requests to stack up and pin PHP
527 // workers for the whole connect window.
528 $response = wp_remote_get($url, array(
529 'timeout' => 4,
530 'headers' => array(
531 'X-Plugin-Token' => $nonce_token,
532 'Accept' => 'application/json',
533 ),
534 ));
535
536 if (is_wp_error($response)) {
537 return null; // transient network failure — keep polling
538 }
539
540 // 400 (bad request) / 429 (throttled) / anything non-200 → keep polling
541 // this round; the push callback and heartbeat self-heal remain in play.
542 if ((int) wp_remote_retrieve_response_code($response) !== 200) {
543 return null;
544 }
545
546 $data = json_decode(wp_remote_retrieve_body($response), true);
547 if (!is_array($data) || empty($data['status'])) {
548 return null;
549 }
550
551 // 'ready' | 'error' are actionable; 'pending' (or anything else) keeps polling.
552 if ($data['status'] === 'ready' || $data['status'] === 'error') {
553 return array(
554 'status' => $data['status'],
555 'connect' => (isset($data['connect']) && is_array($data['connect'])) ? $data['connect'] : array(),
556 );
557 }
558
559 return null;
560 }
561
562 // ------------------------------------------------------------------
563 // Nonce / encrypted token helpers
564 // ------------------------------------------------------------------
565
566 /**
567 * Create Search Atlas Connect Nonce Token.
568 *
569 * Generates a unique, time-limited (15 min), single-use nonce token used to
570 * identify the connect session when Search Atlas calls back with the API key
571 * and Otto UUID.
572 */
573 public function create_searchatlas_nonce_token()
574 {
575 $general_options = Metasync::get_option('general') ?? [];
576 $plugin_auth_token = $general_options['apikey'] ?? '';
577
578 if (empty($plugin_auth_token)) {
579 error_log('MetaSync ERROR: Plugin Auth Token missing from options'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
580 return false;
581 }
582
583 $random_bytes = wp_generate_password(32, false, false);
584 $timestamp = time();
585 $user_id = get_current_user_id();
586
587 $token_data = $random_bytes . '|' . $timestamp . '|' . $user_id . '|' . get_site_url();
588 $sa_connect_token = hash_hmac('sha256', $token_data, $plugin_auth_token . wp_salt('auth'));
589
590 $token_metadata = array(
591 'created' => $timestamp,
592 'expires' => $timestamp + 900,
593 'user_id' => $user_id,
594 'site_url' => get_site_url(),
595 'ip' => $this->get_client_ip(),
596 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 0, 100) : '',
597 'used' => false,
598 'callback_used' => false,
599 'version' => '3.0'
600 );
601
602 $transient_key = 'metasync_sa_connect_token_' . substr(hash('sha256', $sa_connect_token), 0, 32);
603 set_transient($transient_key, $token_metadata, 900);
604
605 set_transient('metasync_sa_connect_active_' . $sa_connect_token, $transient_key, 900);
606
607 // When external object cache (Redis/Memcached/LiteSpeed) is active,
608 // set_transient() writes to cache ONLY — never to wp_options. The REST
609 // callback from SA servers runs in a different process/cache context and
610 // cannot see the cached value. Write directly to DB as a fallback.
611 if (wp_using_ext_object_cache()) {
612 global $wpdb;
613 $active_option = '_transient_metasync_sa_connect_active_' . $sa_connect_token;
614 $metadata_option = '_transient_' . $transient_key;
615 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
616 $timeout_metadata = '_transient_timeout_' . $transient_key;
617 $expires = time() + 900;
618
619 $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
620 $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
621 $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
622 $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
623 }
624
625 return $sa_connect_token;
626 }
627
628 /**
629 * Get client IP address securely
630 */
631 public function get_client_ip()
632 {
633 $ip_headers = array('HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR');
634
635 foreach ($ip_headers as $header) {
636 if (!empty($_SERVER[$header])) {
637 $ip = $_SERVER[$header];
638 if (strpos($ip, ',') !== false) {
639 $ip = trim(explode(',', $ip)[0]);
640 }
641 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
642 return $ip;
643 }
644 }
645 }
646
647 return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
648 }
649
650 /**
651 * Create encrypted Search Atlas connect token with embedded metadata
652 */
653 public function create_encrypted_searchatlas_token($metadata = array())
654 {
655 $payload = array_merge(array(
656 'iat' => time(),
657 'exp' => time() + 1800,
658 'iss' => get_site_url(),
659 'aud' => 'search-atlas-connect',
660 'sub' => 'searchatlas-authentication',
661 'jti' => wp_generate_password(16, false),
662 'nonce' => wp_generate_password(16, false),
663 'version' => '2.0'
664 ), $metadata);
665
666 return $this->wp_encrypt_token($payload);
667 }
668
669 /**
670 * Encrypt token using WordPress SALTs
671 */
672 public function wp_encrypt_token($payload)
673 {
674 try {
675 $serialized = serialize($payload);
676
677 $key_material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
678 $encryption_key = hash('sha256', $key_material, true);
679
680 $iv = random_bytes(16);
681
682 $encrypted = openssl_encrypt($serialized, 'AES-256-CBC', $encryption_key, OPENSSL_RAW_DATA, $iv);
683
684 if ($encrypted === false) {
685 throw new Exception('Encryption failed');
686 }
687
688 $result = $iv . $encrypted;
689
690 return base64_encode($result);
691
692 } catch (Exception $e) {
693 return false;
694 }
695 }
696
697 /**
698 * Decrypt token using WordPress SALTs
699 */
700 public function wp_decrypt_token($encrypted_token)
701 {
702 try {
703 $data = base64_decode($encrypted_token, true);
704
705 if ($data === false || strlen($data) < 16) {
706 return false;
707 }
708
709 $iv = substr($data, 0, 16);
710 $encrypted = substr($data, 16);
711
712 $key_material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
713 $encryption_key = hash('sha256', $key_material, true);
714
715 $serialized = openssl_decrypt($encrypted, 'AES-256-CBC', $encryption_key, OPENSSL_RAW_DATA, $iv);
716
717 if ($serialized === false) {
718 return false;
719 }
720
721 $payload = unserialize($serialized, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
722
723 if (!is_array($payload) || !isset($payload['exp'], $payload['iat'])) {
724 return false;
725 }
726
727 if ($payload['exp'] < time()) {
728 return false;
729 }
730
731 return $payload;
732
733 } catch (Exception $e) {
734 return false;
735 }
736 }
737
738 // ------------------------------------------------------------------
739 // Cleanup helpers
740 // ------------------------------------------------------------------
741
742 /**
743 * @deprecated No longer needed with simplified token system
744 */
745 public function cleanup_searchatlas_nonce_tokens()
746 {
747 return 0;
748 }
749
750 /**
751 * Cleanup Search Atlas connect rate limiting data
752 */
753 public function cleanup_searchatlas_rate_limits()
754 {
755 global $wpdb;
756
757 try {
758 $rate_limit_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
759 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
760 ARRAY_A
761 );
762
763 $cleaned_count = 0;
764
765 foreach ($rate_limit_transients as $transient) {
766 $transient_name = str_replace('_transient_', '', $transient['option_name']);
767 delete_transient($transient_name);
768 $cleaned_count++;
769 }
770
771 return $cleaned_count;
772
773 } catch (Exception $e) {
774 return 0;
775 }
776 }
777
778 /**
779 * Clear cached JWT tokens
780 */
781 public function clear_jwt_token_cache()
782 {
783 global $wpdb;
784
785 $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
786 $wpdb->prepare(
787 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
788 '_transient_metasync_jwt_token_%'
789 )
790 );
791
792 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
793 $wpdb->prepare(
794 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
795 '_transient_timeout_metasync_jwt_token_%'
796 )
797 );
798 }
799
800 // ------------------------------------------------------------------
801 // JWT token management
802 // ------------------------------------------------------------------
803
804 /**
805 * Get active JWT token for the plugin.
806 * Public static method accessible from anywhere in the plugin.
807 *
808 * @param bool $force_refresh Force generation of new token even if cached one exists
809 * @return string|false JWT token on success, false on failure
810 */
811 public static function get_active_jwt_token($force_refresh = false)
812 {
813 $api_key = Metasync::get_searchatlas_api_key();
814 if ($api_key === false) {
815 $api_key = '';
816 }
817
818 if (empty($api_key)) {
819 return false;
820 }
821
822 if (!$force_refresh) {
823 $cache_key = 'metasync_jwt_token_' . md5($api_key);
824 $cached_token_data = get_transient($cache_key);
825
826 if ($cached_token_data && is_array($cached_token_data)) {
827 $expires_with_buffer = $cached_token_data['expires'] - 300;
828 if (time() < $expires_with_buffer && !empty($cached_token_data['token'])) {
829 return $cached_token_data['token'];
830 }
831 }
832 }
833
834 return self::instance()->get_fresh_jwt_token();
835 }
836
837 /**
838 * Rotate the MCP signing secret so all previously issued tokens are invalid.
839 */
840 public function revoke_mcp_jwt_tokens()
841 {
842 delete_option('metasync_jwt_secret');
843 wp_cache_delete('metasync_jwt_secret', 'options');
844 }
845
846 /**
847 * Return a valid cached JWT without making a network request.
848 *
849 * Non-blocking telemetry must never refresh credentials as a side effect.
850 *
851 * @return string|false Cached JWT token, or false when none is available.
852 */
853 public static function get_cached_jwt_token()
854 {
855 $api_key = Metasync::get_searchatlas_api_key();
856 if ($api_key === false || $api_key === '') {
857 return false;
858 }
859
860 $cache_key = 'metasync_jwt_token_' . md5($api_key);
861 $cached_token_data = get_transient($cache_key);
862 if (!is_array($cached_token_data) || empty($cached_token_data['token'])) {
863 return false;
864 }
865
866 $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
867 if ($expires <= time() + 300) {
868 return false;
869 }
870
871 return (string) $cached_token_data['token'];
872 }
873
874 /**
875 * Get fresh JWT token from Search Atlas API with caching
876 *
877 * @return string|false JWT token on success, false on failure
878 */
879 public function get_fresh_jwt_token()
880 {
881 $api_key = Metasync::get_searchatlas_api_key();
882 if ($api_key === false) {
883 $api_key = '';
884 }
885
886 if (empty($api_key)) {
887 return false;
888 }
889
890 $cache_key = 'metasync_jwt_token_' . md5($api_key);
891 $cached_token_data = get_transient($cache_key);
892
893 if ($cached_token_data && is_array($cached_token_data)) {
894 $expires_with_buffer = $cached_token_data['expires'] - 300;
895 if (time() < $expires_with_buffer && !empty($cached_token_data['token'])) {
896 return $cached_token_data['token'];
897 }
898 }
899
900 $api_domain = class_exists('Metasync_Endpoint_Manager')
901 ? Metasync_Endpoint_Manager::get_endpoint('API_DOMAIN')
902 : Metasync::API_DOMAIN;
903 $url = $api_domain . '/api/customer/account/generate-jwt-from-api-key/';
904
905 $args = array(
906 'method' => 'POST',
907 'headers' => array(
908 'X-API-KEY' => $api_key,
909 'Content-Type' => 'application/json'
910 ),
911 'timeout' => 15
912 );
913
914 try {
915 $response = wp_remote_post($url, $args);
916
917 if (is_wp_error($response)) {
918 error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
919 return false;
920 }
921
922 $response_code = wp_remote_retrieve_response_code($response);
923 $response_body = wp_remote_retrieve_body($response);
924
925 if ($response_code !== 200) {
926 error_log('MetaSync: JWT token API returned error code ' . $response_code); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
927 return false;
928 }
929
930 $data = json_decode($response_body, true);
931
932 if (!$data || !isset($data['token'], $data['expires'])) {
933 error_log('MetaSync: Invalid JWT token API response format'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
934 return false;
935 }
936
937 $token_data = array(
938 'token' => $data['token'],
939 'expires' => $data['expires'],
940 'created_at' => time()
941 );
942
943 $cache_duration = min($data['expires'] - time(), 24 * 3600);
944 set_transient($cache_key, $token_data, $cache_duration);
945
946 return $data['token'];
947
948 } catch (Exception $e) {
949 error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
950 return false;
951 }
952 }
953
954 // ------------------------------------------------------------------
955 // Authentication reset
956 // ------------------------------------------------------------------
957
958 /**
959 * Save the options blob so that removing a key actually removes it.
960 *
961 * Metasync::set_option() goes through update_option(), and WordPress pipes
962 * every update_option() call for a registered setting through
963 * sanitize_option() — which runs Metasync_Settings_Registration::sanitize(),
964 * the callback register_setting() attached. That sanitizer is built for a
965 * Settings-API form POST, where the submission carries only the fields of
966 * one tab: it takes the *stored* option as its base layer and merges the
967 * incoming array over it, deliberately so that keys a partial submission
968 * omits survive rather than being wiped.
969 *
970 * Correct for a form. Fatal here. Disconnecting works by unsetting keys and
971 * saving the result, and the sanitizer re-reads the pre-write option as its
972 * base, so every key this handler removed came straight back — the API key
973 * byte-identical to the one just cleared. The account looked connected again
974 * on the next page load.
975 *
976 * So this write detaches that callback for its own duration and restores it
977 * afterwards. Scoped to the disconnect handler rather than changed inside
978 * set_option(): the merge is right for the form path, and the sanitizer also
979 * carries the whitelabel password encryption and recovery-password guards
980 * that other programmatic writes still want.
981 *
982 * @param array $options The complete options array to store verbatim.
983 * @return bool Whether the write succeeded.
984 */
985 private function write_options_without_stored_merge($options)
986 {
987 $hook = 'sanitize_option_' . Metasync::option_name;
988
989 // register_setting() attaches it at the default priority against the
990 // registration singleton, so the same callable identity removes it.
991 $callback = class_exists('Metasync_Settings_Registration')
992 ? array(Metasync_Settings_Registration::instance(), 'sanitize')
993 : null;
994
995 $was_attached = ($callback !== null) && remove_filter($hook, $callback);
996
997 $save_result = Metasync::set_option($options);
998
999 if ($was_attached) {
1000 add_filter($hook, $callback);
1001 }
1002
1003 return $save_result;
1004 }
1005
1006 /**
1007 * Reset Search Atlas Authentication
1008 * Clears all authentication data and tokens
1009 */
1010 public function reset_searchatlas_authentication()
1011 {
1012 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_reset_auth_nonce')) {
1013 wp_send_json_error(array(
1014 'message' => 'Security verification failed. Please refresh the page and try again.',
1015 'code' => 'invalid_nonce'
1016 ));
1017 return;
1018 }
1019
1020 if (!current_user_can('manage_options')) {
1021 wp_send_json_error(array(
1022 'message' => 'You do not have permission to reset authentication.',
1023 'code' => 'insufficient_permissions'
1024 ));
1025 return;
1026 }
1027
1028 try {
1029 $options = Metasync::get_option();
1030
1031 if (!is_array($options)) {
1032 $options = array();
1033 }
1034
1035 if (!isset($options['general'])) {
1036 $options['general'] = array();
1037 }
1038
1039 $cleared_data = array();
1040
1041 if (isset($options['general']['searchatlas_api_key'])) {
1042 // Record only a masked marker (last 4 chars) — never the stored
1043 // ciphertext or the cleartext key.
1044 $disconnect_key = Metasync::get_searchatlas_api_key();
1045 $cleared_data['searchatlas_api_key'] = (is_string($disconnect_key) && $disconnect_key !== '')
1046 ? str_repeat('•', 4) . substr($disconnect_key, -4)
1047 : '(removed)';
1048 unset($options['general']['searchatlas_api_key']);
1049 }
1050
1051 if (isset($options['general']['otto_pixel_uuid'])) {
1052 $cleared_data['otto_pixel_uuid'] = $options['general']['otto_pixel_uuid'];
1053 unset($options['general']['otto_pixel_uuid']);
1054 }
1055
1056 if (isset($options['general']['send_auth_token_timestamp'])) {
1057 $cleared_data['send_auth_token_timestamp'] = $options['general']['send_auth_token_timestamp'];
1058 unset($options['general']['send_auth_token_timestamp']);
1059 }
1060
1061 if (isset($options['general']['last_heart_beat'])) {
1062 $cleared_data['last_heart_beat'] = $options['general']['last_heart_beat'];
1063 unset($options['general']['last_heart_beat']);
1064 }
1065
1066 # drop the manual "Sync Now" cooldown stamp too — a stale
1067 # cooldown surviving disconnect blocks the first sync after the
1068 # user reconnects or saves a new API key.
1069 if (isset($options['general']['last_manual_sync'])) {
1070 $cleared_data['last_manual_sync'] = $options['general']['last_manual_sync'];
1071 unset($options['general']['last_manual_sync']);
1072 }
1073
1074 # Clear the dedicated heartbeat throttle option (moved
1075 # throttle timestamps out of the main blob).
1076 delete_option(Metasync::heartbeat_throttle_option);
1077 $cleared_data['heartbeat_throttle'] = 'removed';
1078
1079 $save_result = $this->write_options_without_stored_merge($options);
1080 Metasync::invalidate_api_key_cache();
1081
1082 if (!$save_result) {
1083 throw new Exception('Failed to save updated plugin options');
1084 }
1085
1086 delete_option('metasync_wp_sa_connect_token');
1087 $cleared_data['wp_sa_connect_token'] = 'removed';
1088
1089 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
1090 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
1091
1092 // White-label identity is preserved across the authentication
1093 // reset: the reseller's branding, logos, domains, custom OTTO
1094 // name, visibility/access controls, password and recovery
1095 // settings, and the dedicated whitelabel_user option must survive
1096 // the disconnect. Only authentication tokens and credentials are
1097 // cleared above. Do not delete the whitelabel blob or the
1098 // whitelabel_user option here.
1099
1100 $this->clear_jwt_token_cache();
1101 $this->revoke_mcp_jwt_tokens();
1102 $cleared_data['jwt_token_cache'] = 'cleared';
1103
1104 $this->cleanup_searchatlas_rate_limits();
1105 $cleared_data['rate_limits'] = 'cleared';
1106
1107 $otto_uuid = $cleared_data['otto_pixel_uuid'] ?? '';
1108 if (!empty($otto_uuid)) {
1109 delete_transient(Metasync_Heartbeat_Manager::public_hash_cache_key($otto_uuid));
1110 }
1111 $cleared_data['public_hash_cache'] = 'cleared';
1112
1113 delete_transient('metasync_heartbeat_status_cache');
1114 # also drop the last-known-state fallback. With it left at
1115 # `true`, the header badge flips between "Not Connected" and
1116 # "Warning" across refreshes depending on whether the status cache
1117 # transient is alive once an API key exists again.
1118 delete_option('metasync_last_known_connection_state');
1119 Metasync_Admin_Navigation::invalidate_admin_bar_status_cache();
1120 $cleared_data['heartbeat_cache'] = 'cleared';
1121 $cleared_data['last_known_connection_state'] = 'cleared';
1122
1123 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
1124
1125 // Consent-gated analytics: churn signal, the opposite of connected.
1126 Metasync_PostHog::feature('searchatlas_disconnected', array(
1127 'method' => 'reset_auth',
1128 'was_connected' => isset($cleared_data['searchatlas_api_key']),
1129 ));
1130
1131 // Re-synchronise the branded plugin file headers so the WordPress
1132 // plugin list and the plugin UI stay branded after the
1133 // authentication reset. Only attempted when white-label branding
1134 // exists, and a failed sync is logged as a warning, not reported
1135 // as a reset failure: the credentials have already been cleared,
1136 // and hosts with read-only plugin files would otherwise never see
1137 // a successful reset.
1138 if (Metasync::is_whitelabel_enabled()) {
1139 if (!class_exists('Metasync_Activator')) {
1140 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
1141 }
1142 if (!Metasync_Activator::sync_plugin_file_headers()) {
1143 error_log('MetaSync: failed to re-synchronise branded plugin file headers after the authentication reset.');
1144 }
1145 }
1146
1147 wp_send_json_success(array(
1148 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
1149 'cleared_data' => $cleared_data,
1150 'timestamp' => current_time('mysql', true)
1151 ));
1152
1153 } catch (Exception $e) {
1154 error_log('Authentication Reset Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1155 wp_send_json_error(array(
1156 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
1157 'code' => 'reset_failed',
1158 'error' => $e->getMessage()
1159 ));
1160 }
1161 }
1162
1163 // ------------------------------------------------------------------
1164 // Test / debug endpoints
1165 // ------------------------------------------------------------------
1166
1167 /**
1168 * Test the enhanced Search Atlas connect token system (development/debugging)
1169 */
1170 public function test_enhanced_searchatlas_tokens()
1171 {
1172 if (!current_user_can('manage_options')) {
1173 return false;
1174 }
1175
1176 $general_options = Metasync::get_option('general') ?? [];
1177 $test_token = $general_options['apikey'] ?? null;
1178
1179 $apikey = $general_options['apikey'] ?? '';
1180
1181 $encrypted_token = $this->create_encrypted_searchatlas_token(['test' => 'data', 'user_id' => get_current_user_id()]);
1182 if ($encrypted_token) {
1183 $decrypted = $this->wp_decrypt_token($encrypted_token);
1184 }
1185
1186 return true;
1187 }
1188
1189 /**
1190 * Test Search Atlas connect AJAX endpoint (development/debugging)
1191 */
1192 public function test_searchatlas_ajax_endpoint()
1193 {
1194 if (!current_user_can('manage_options')) {
1195 wp_send_json_error(array(
1196 'message' => 'Insufficient permissions for AJAX test',
1197 'required_capability' => 'manage_options'
1198 ));
1199 return;
1200 }
1201
1202 $nonce_valid = false;
1203 if (isset($_POST['nonce'])) {
1204 $nonce_valid = wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_sa_connect_nonce');
1205 }
1206
1207 wp_send_json_success(array(
1208 'message' => 'AJAX endpoint is working correctly',
1209 'timestamp' => current_time('mysql', true),
1210 'user_id' => get_current_user_id(),
1211 'endpoint' => 'test_searchatlas_ajax_endpoint',
1212 'nonce_valid' => $nonce_valid,
1213 'debug_info' => array(
1214 'post_action' => isset($_POST['action']) ? sanitize_text_field(wp_unslash($_POST['action'])) : 'NOT SET',
1215 'has_nonce' => isset($_POST['nonce']),
1216 'user_can_manage_options' => current_user_can('manage_options')
1217 )
1218 ));
1219 }
1220
1221 /**
1222 * Test whitelabel domain configuration (development/debugging)
1223 */
1224 public function test_whitelabel_domain()
1225 {
1226 if (!current_user_can('manage_options')) {
1227 wp_send_json_error('Administrator access required');
1228 return;
1229 }
1230
1231 $whitelabel_settings = Metasync::get_whitelabel_settings();
1232
1233 $is_enabled = Metasync::is_whitelabel_enabled();
1234
1235 $effective_domain = Metasync_Admin::get_effective_dashboard_domain();
1236 $metasync_domain = Metasync::get_dashboard_domain();
1237
1238 $whitelabel_logo = Metasync::get_whitelabel_logo();
1239
1240 $default_domain = Metasync::DASHBOARD_DOMAIN;
1241
1242 $whitelabel_company_name = Metasync::get_whitelabel_company_name();
1243
1244 $effective_plugin_name = Metasync::get_effective_plugin_name('Test Plugin');
1245
1246 wp_send_json_success(array(
1247 'whitelabel_settings' => $whitelabel_settings,
1248 'is_enabled' => $is_enabled,
1249 'effective_domain' => $effective_domain,
1250 'whitelabel_logo' => $whitelabel_logo,
1251 'whitelabel_company_name' => $whitelabel_company_name,
1252 'effective_plugin_name' => $effective_plugin_name,
1253 'default_domain' => $default_domain,
1254 'override_active' => $effective_domain !== $default_domain
1255 ));
1256 }
1257
1258 // ------------------------------------------------------------------
1259 // Whitelabel session / password management
1260 // ------------------------------------------------------------------
1261
1262 /**
1263 * Handle session management early in the admin lifecycle
1264 */
1265 public function handle_session_management_early()
1266 {
1267 if (!is_admin()) {
1268 return;
1269 }
1270
1271 $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1272 $current_page = isset($_GET['page']) ? $_GET['page'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1273
1274 $whitelabel_settings = Metasync::get_whitelabel_settings();
1275 $user_password = $whitelabel_settings['settings_password'] ?? '';
1276 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
1277
1278 $protected_tabs = [];
1279 if (!empty($user_password)) {
1280 $protected_tabs[] = 'whitelabel';
1281 }
1282 if ($hide_settings_enabled && !empty($user_password)) {
1283 $protected_tabs = ['general', 'whitelabel', 'advanced'];
1284 }
1285
1286 if (strpos($current_page, Metasync_Admin::$page_slug) === 0 && in_array($active_tab, $protected_tabs)) {
1287 if ((defined('REST_REQUEST') && REST_REQUEST) ||
1288 (defined('DOING_AJAX') && DOING_AJAX) ||
1289 (defined('DOING_CRON') && DOING_CRON)) {
1290 return;
1291 }
1292
1293 $this->handle_whitelabel_session_logic();
1294 }
1295 }
1296
1297 /**
1298 * Handle whitelabel authentication logic (login/logout/validation)
1299 * Uses Metasync_Auth_Manager instead of sessions for better compatibility
1300 */
1301 public function handle_whitelabel_session_logic()
1302 {
1303 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1304
1305 if (isset($_POST['whitelabel_logout'])) {
1306 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1307 $auth->revoke_access();
1308
1309 $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1310 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1311 wp_safe_redirect($redirect_url);
1312 exit;
1313 }
1314 }
1315
1316 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1317 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1318 $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1319
1320 $lock_owner = '';
1321 if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1322 return;
1323 }
1324 $shutdown_lock_owner = $lock_owner;
1325 register_shutdown_function(function () use ($shutdown_lock_owner) {
1326 Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1327 });
1328
1329 try {
1330 // This request may have cached settings before a reset
1331 // completed. Refresh after taking the shared lock, then
1332 // verify and grant against one consistent password state.
1333 Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1334 if (function_exists('wp_cache_delete')) wp_cache_delete('metasync_auth_revoke_epoch_whitelabel', 'options');
1335 $auth->verify_and_grant($submitted_password, $this->get_whitelabel_valid_passwords(), false);
1336 } finally {
1337 Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1338 $lock_owner = '';
1339 }
1340 }
1341 }
1342 }
1343
1344 /**
1345 * Handle whitelabel password early before WordPress filters it out
1346 */
1347 public function handle_whitelabel_password_early()
1348 {
1349 if (!current_user_can('manage_options')) {
1350 return;
1351 }
1352
1353 if (
1354 !isset($_POST['meta_sync_nonce'])
1355 || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')
1356 ) {
1357 return;
1358 }
1359
1360 if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1361
1362 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1363 $submitted_password = (string) wp_unslash($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1364 $lock_owner = '';
1365 if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1366 return;
1367 }
1368 $shutdown_lock_owner = $lock_owner;
1369 register_shutdown_function(function () use ($shutdown_lock_owner) {
1370 Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1371 });
1372
1373 try {
1374 Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1375 $current_options = Metasync::get_option();
1376
1377 if (!isset($current_options['whitelabel'])) {
1378 $current_options['whitelabel'] = [];
1379 }
1380
1381 $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1382 $current_options['whitelabel']['updated_at'] = time();
1383
1384 update_option(Metasync_Admin::option_key, $current_options);
1385 } finally {
1386 Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1387 $lock_owner = '';
1388 }
1389 }
1390 }
1391 }
1392
1393 /**
1394 * Return the site-specific passwords accepted by the whitelabel gate.
1395 *
1396 * @return array<int, string>
1397 */
1398 private function get_whitelabel_valid_passwords()
1399 {
1400 $user_password = Metasync::get_whitelabel_password();
1401
1402 return $user_password === '' ? array() : array($user_password);
1403 }
1404 }
1405