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

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