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

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