PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-connect-manager.php +390 -71 2.6.18 → 2.7.2 View file →
@@ -88,9 +88,9 @@
88 88 $general_options = Metasync::get_option('general') ?? [];
89 89 $plugin_auth_token = $general_options['apikey'] ?? '';
90 90
91 91 if (empty($plugin_auth_token)) {
92 - error_log('MetaSync ERROR: Plugin Auth Token missing from options - should have been generated during activation');
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 93 return false;
94 94 }
95 95
96 96 return $plugin_auth_token;
@@ -188,9 +188,9 @@
188 188 wp_send_json_error(array('message' => 'Failed to save new token'));
189 189 }
190 190
191 191 } catch (Exception $e) {
192 - error_log('Plugin Auth Token Refresh Error: ' . $e->getMessage());
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 193 wp_send_json_error(array('message' => 'Error generating new token'));
194 194 }
195 195 }
196 196
@@ -222,9 +222,9 @@
222 222 wp_send_json_error(array('message' => 'Plugin Auth Token not found'));
223 223 }
224 224
225 225 } catch (Exception $e) {
226 - error_log('Get Plugin Auth Token Error: ' . $e->getMessage());
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 227 wp_send_json_error(array('message' => 'Error retrieving Plugin Auth Token'));
228 228 }
229 229 }
230 230
@@ -270,15 +270,28 @@
270 270 $domain = str_replace('://www.', '://', get_site_url());
271 271
272 272 $dashboard_domain = Metasync_Admin::get_effective_dashboard_domain();
273 273
274 - $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query([
274 + $connect_args = [
275 275 'nonce_token' => $sa_connect_token,
276 276 'domain' => $domain,
277 277 'callback_url' => get_rest_url(null, 'metasync/v1/searchatlas/connect/callback'),
278 278 'return_url' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug)
279 - ]);
279 + ];
280 280
281 + if (Metasync_Headless_Config::is_active()) {
282 + $connect_args['frontend_domain'] = Metasync_Headless_Config::get_frontend_domain();
283 + }
284 +
285 + $sa_connect_url = $dashboard_domain . '/sso/wordpress?' . http_build_query($connect_args);
286 +
287 + // Consent-gated analytics: the denominator for searchatlas_connected.
288 + $existing_key = Metasync::get_searchatlas_api_key();
289 + Metasync_PostHog::feature('searchatlas_connect_started', array(
290 + 'is_reconnection' => is_string($existing_key) && $existing_key !== '',
291 + 'is_whitelabel' => (bool) Metasync::is_whitelabel_enabled(),
292 + ));
293 +
281 294 wp_send_json_success(array(
282 295 'connect_url' => $sa_connect_url,
283 296 'nonce_token' => $sa_connect_token,
284 297 'debug_info' => array(
@@ -323,9 +336,9 @@
323 336 if (!$this_auth_completed && wp_using_ext_object_cache()) {
324 337 global $wpdb;
325 338
326 339 // Check expiry first
327 - $timeout = $wpdb->get_var(
340 + $timeout = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
328 341 $wpdb->prepare(
329 342 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
330 343 '_transient_timeout_' . $success_key
331 344 )
@@ -332,12 +345,12 @@
332 345 );
333 346
334 347 if ($timeout && (int) $timeout < time()) {
335 348 // 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));
349 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
350 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
338 351 } else {
339 - $db_value = $wpdb->get_var(
352 + $db_value = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
340 353 $wpdb->prepare(
341 354 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
342 355 '_transient_' . $success_key
343 356 )
@@ -353,10 +366,10 @@
353 366 delete_transient($success_key);
354 367 // Also clean up DB fallback rows
355 368 if (wp_using_ext_object_cache()) {
356 369 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));
370 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
371 + $wpdb->delete($wpdb->options, array('option_name' => '_transient_timeout_' . $success_key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
359 372 }
360 373
361 374 // Get current settings to return connection status
362 375 $general_settings = Metasync::get_option('general') ?? [];
@@ -376,11 +389,177 @@
376 389 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain()
377 390 ));
378 391 }
379 392
393 + // WP-558 (Option A): pull-based connect fallback.
394 + // The push callback (SA server -> this site) is silently dropped by
395 + // Cloudflare/WAFs/basic-auth/"coming soon" plugins on many hosts, which
396 + // is the #1 connect failure. Outbound requests from this site are NOT
397 + // firewalled, so poll the CA endpoint for the staged result using the
398 + // same nonce token we already sent to the SSO flow. The push callback
399 + // above is checked first and stays intact as a fallback — pull is purely
400 + // additive, and mark_searchatlas_nonce_used() is idempotent.
401 + if (!empty($nonce_token) && apply_filters('metasync_enable_pull_connect', true)) {
402 + $pull = $this->poll_pull_based_connect_result($nonce_token);
403 +
404 + if (is_array($pull)) {
405 + $connect = (isset($pull['connect']) && is_array($pull['connect'])) ? $pull['connect'] : array();
406 + $pull_status_code = isset($connect['status_code']) ? intval($connect['status_code']) : 0;
407 +
408 + if ($pull['status'] === 'ready'
409 + && $pull_status_code === 200
410 + && !empty($connect['api_key'])
411 + && !empty($connect['uuid'])) {
412 +
413 + // Persist through the EXACT same path the push callback uses.
414 + // Run the pulled payload through the SAME validation +
415 + // normalization the push callback applies
416 + // (extract_and_validate_searchatlas_params) BEFORE storing, so
417 + // pull and push share identical acceptance criteria: a stringy
418 + // is_whitelabel ("false"/"0") is coerced to a real bool, and a
419 + // malformed/oversized api_key or uuid is rejected instead of
420 + // stored verbatim. Only after this is the storage provably
421 + // identical to the push body.
422 + $rest_api = new Metasync_Rest_Api('metasync', defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
423 + $validated = $rest_api->extract_and_validate_searchatlas_params($connect);
424 +
425 + if (!is_wp_error($validated)) {
426 + $stored = $rest_api->mark_searchatlas_nonce_used(
427 + $nonce_token,
428 + $validated['api_key'],
429 + $validated['uuid'],
430 + $validated['status_code'],
431 + $validated['is_whitelabel'],
432 + $validated['whitelabel_domain'],
433 + $validated['whitelabel_logo'],
434 + $validated['whitelabel_company_name'],
435 + $validated['whitelabel_otto']
436 + );
437 +
438 + if ($stored) {
439 + // mark_searchatlas_nonce_used() sets the one-time success
440 + // transient keyed by md5($nonce_token); clear it since we
441 + // report success directly here (avoids a stale replay flag).
442 + delete_transient('metasync_sa_connect_success_' . md5($nonce_token));
443 +
444 + $general_settings = Metasync::get_option('general') ?? [];
445 +
446 + // Never return the cleartext key to the browser.
447 + $decrypted_key = Metasync::get_searchatlas_api_key();
448 + $masked_key = (is_string($decrypted_key) && $decrypted_key !== '')
449 + ? str_repeat('*', 8) . substr($decrypted_key, -4)
450 + : '';
451 +
452 + wp_send_json_success(array(
453 + 'updated' => true,
454 + 'masked_key' => $masked_key,
455 + 'otto_pixel_uuid' => $general_settings['otto_pixel_uuid'] ?? '',
456 + 'status_code' => 200,
457 + 'whitelabel_enabled' => !empty($general_settings['white_label_plugin_name']),
458 + 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
459 + 'source' => 'pull',
460 + ));
461 + }
462 + }
463 + // A WP_Error from validation (malformed staged payload) is not
464 + // persisted; we fall through to updated:false so polling
465 + // continues and the push callback / heartbeat self-heal remain.
466 + } elseif ($pull['status'] === 'error') {
467 + // Honest state: surface the real server error (e.g. "Domain not
468 + // found for customer") instead of the silent 60s timeout. The
469 + // existing JS branches on status_code (404/500/…) to render it.
470 + // Once per connect attempt, even if the poll repeats.
471 + Metasync_PostHog::feature_once('searchatlas_connect_failed', array(
472 + 'status_code' => $pull_status_code ?: 400,
473 + 'source' => 'pull',
474 + ), md5($nonce_token), 15 * MINUTE_IN_SECONDS);
475 + wp_send_json_success(array(
476 + 'updated' => true,
477 + 'status_code' => $pull_status_code ?: 400,
478 + 'message' => isset($connect['message']) ? sanitize_text_field($connect['message']) : '',
479 + 'effective_domain' => Metasync_Admin::get_effective_dashboard_domain(),
480 + 'source' => 'pull',
481 + ));
482 + }
483 + }
484 + }
485 +
380 486 wp_send_json_success(array('updated' => false));
381 487 }
382 488
489 + /**
490 + * WP-558 (Option A): poll the CA pull-based connect-result endpoint.
491 + *
492 + * Outbound GET to `<CA base>/api/wp-plugin-connect-result/?url=<site_url>` with
493 + * the same SSO nonce sent as the `X-Plugin-Token` header. `url` uses the exact
494 + * expression the SSO flow registered (str_replace('://www.','://', get_site_url()))
495 + * so it matches the server's `(hostname, sha256(nonce))` key. Outbound traffic
496 + * from the site bypasses the Cloudflare/WAF/basic-auth layers that silently drop
497 + * the inbound push callback.
498 + *
499 + * @param string $nonce_token The SSO connect nonce (same value sent to the dashboard).
500 + * @return array|null ['status' => 'ready'|'error', 'connect' => array] when the
501 + * server has a staged result; null to keep polling (pending,
502 + * throttled, bad request, or transport failure — the push
503 + * callback remains a fallback in every null case).
504 + */
505 + private function poll_pull_based_connect_result($nonce_token)
506 + {
507 + // Server rejects tokens over 512 chars with a 400; don't waste the request.
508 + if (empty($nonce_token) || strlen($nonce_token) > 512) {
509 + return null;
510 + }
511 +
512 + $base = class_exists('Metasync_Endpoint_Manager')
513 + ? Metasync_Endpoint_Manager::get_endpoint('CA_API_DOMAIN')
514 + : (class_exists('Metasync') ? Metasync::CA_API_DOMAIN : 'https://ca.searchatlas.com');
515 +
516 + // Same base + token convention as the activation announce ping
517 + // (Metasync_Activator::send_announce_ping). Send the EXACT same domain
518 + // expression the SSO flow registered with CA — generate_searchatlas_connect_url()
519 + // uses str_replace('://www.', '://', get_site_url()) — so the server's
520 + // (hostname, sha256(nonce)) lookup matches byte-for-byte instead of relying
521 + // on server-side www normalization or home_url == site_url.
522 + $domain = str_replace('://www.', '://', get_site_url());
523 + $url = rtrim($base, '/') . '/api/wp-plugin-connect-result/?url=' . rawurlencode($domain);
524 +
525 + // Timeout kept below the JS poll cadence (5s) so a slow/unreachable CA
526 + // can't cause overlapping admin-ajax requests to stack up and pin PHP
527 + // workers for the whole connect window.
528 + $response = wp_remote_get($url, array(
529 + 'timeout' => 4,
530 + 'headers' => array(
531 + 'X-Plugin-Token' => $nonce_token,
532 + 'Accept' => 'application/json',
533 + ),
534 + ));
535 +
536 + if (is_wp_error($response)) {
537 + return null; // transient network failure — keep polling
538 + }
539 +
540 + // 400 (bad request) / 429 (throttled) / anything non-200 → keep polling
541 + // this round; the push callback and heartbeat self-heal remain in play.
542 + if ((int) wp_remote_retrieve_response_code($response) !== 200) {
543 + return null;
544 + }
545 +
546 + $data = json_decode(wp_remote_retrieve_body($response), true);
547 + if (!is_array($data) || empty($data['status'])) {
548 + return null;
549 + }
550 +
551 + // 'ready' | 'error' are actionable; 'pending' (or anything else) keeps polling.
552 + if ($data['status'] === 'ready' || $data['status'] === 'error') {
553 + return array(
554 + 'status' => $data['status'],
555 + 'connect' => (isset($data['connect']) && is_array($data['connect'])) ? $data['connect'] : array(),
556 + );
557 + }
558 +
559 + return null;
560 + }
561 +
383 562 // ------------------------------------------------------------------
384 563 // Nonce / encrypted token helpers
385 564 // ------------------------------------------------------------------
386 565
@@ -396,9 +575,9 @@
396 575 $general_options = Metasync::get_option('general') ?? [];
397 576 $plugin_auth_token = $general_options['apikey'] ?? '';
398 577
399 578 if (empty($plugin_auth_token)) {
400 - error_log('MetaSync ERROR: Plugin Auth Token missing from options');
579 + error_log('MetaSync ERROR: Plugin Auth Token missing from options'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
401 580 return false;
402 581 }
403 582
404 583 $random_bytes = wp_generate_password(32, false, false);
@@ -436,12 +615,12 @@
436 615 $timeout_active = '_transient_timeout_metasync_sa_connect_active_' . $sa_connect_token;
437 616 $timeout_metadata = '_transient_timeout_' . $transient_key;
438 617 $expires = time() + 900;
439 618
440 - $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no'));
441 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no'));
442 - $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no'));
443 - $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no'));
619 + $wpdb->replace($wpdb->options, array('option_name' => $active_option, 'option_value' => $transient_key, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
620 + $wpdb->replace($wpdb->options, array('option_name' => $timeout_active, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
621 + $wpdb->replace($wpdb->options, array('option_name' => $metadata_option, 'option_value' => maybe_serialize($token_metadata), 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
622 + $wpdb->replace($wpdb->options, array('option_name' => $timeout_metadata, 'option_value' => $expires, 'autoload' => 'no')); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
444 623 }
445 624
446 625 return $sa_connect_token;
447 626 }
@@ -575,9 +754,9 @@
575 754 {
576 755 global $wpdb;
577 756
578 757 try {
579 - $rate_limit_transients = $wpdb->get_results(
758 + $rate_limit_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
580 759 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
581 760 ARRAY_A
582 761 );
583 762
@@ -602,9 +781,9 @@
602 781 public function clear_jwt_token_cache()
603 782 {
604 783 global $wpdb;
605 784
606 - $deleted = $wpdb->query(
785 + $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
607 786 $wpdb->prepare(
608 787 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
609 788 '_transient_metasync_jwt_token_%'
610 789 )
@@ -609,9 +788,9 @@
609 788 '_transient_metasync_jwt_token_%'
610 789 )
611 790 );
612 791
613 - $wpdb->query(
792 + $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- manual transient rows on the options table (autoload=no) — mirrors core transient storage; TTL reads need the paired timeout row
614 793 $wpdb->prepare(
615 794 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
616 795 '_transient_timeout_metasync_jwt_token_%'
617 796 )
@@ -655,8 +834,45 @@
655 834 return self::instance()->get_fresh_jwt_token();
656 835 }
657 836
658 837 /**
838 + * Rotate the MCP signing secret so all previously issued tokens are invalid.
839 + */
840 + public function revoke_mcp_jwt_tokens()
841 + {
842 + delete_option('metasync_jwt_secret');
843 + wp_cache_delete('metasync_jwt_secret', 'options');
844 + }
845 +
846 + /**
847 + * Return a valid cached JWT without making a network request.
848 + *
849 + * Non-blocking telemetry must never refresh credentials as a side effect.
850 + *
851 + * @return string|false Cached JWT token, or false when none is available.
852 + */
853 + public static function get_cached_jwt_token()
854 + {
855 + $api_key = Metasync::get_searchatlas_api_key();
856 + if ($api_key === false || $api_key === '') {
857 + return false;
858 + }
859 +
860 + $cache_key = 'metasync_jwt_token_' . md5($api_key);
861 + $cached_token_data = get_transient($cache_key);
862 + if (!is_array($cached_token_data) || empty($cached_token_data['token'])) {
863 + return false;
864 + }
865 +
866 + $expires = isset($cached_token_data['expires']) ? (int) $cached_token_data['expires'] : 0;
867 + if ($expires <= time() + 300) {
868 + return false;
869 + }
870 +
871 + return (string) $cached_token_data['token'];
872 + }
873 +
874 + /**
659 875 * Get fresh JWT token from Search Atlas API with caching
660 876 *
661 877 * @return string|false JWT token on success, false on failure
662 878 */
@@ -698,9 +914,9 @@
698 914 try {
699 915 $response = wp_remote_post($url, $args);
700 916
701 917 if (is_wp_error($response)) {
702 - error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message());
918 + error_log('MetaSync: JWT token API request failed - ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
703 919 return false;
704 920 }
705 921
706 922 $response_code = wp_remote_retrieve_response_code($response);
@@ -706,9 +922,9 @@
706 922 $response_code = wp_remote_retrieve_response_code($response);
707 923 $response_body = wp_remote_retrieve_body($response);
708 924
709 925 if ($response_code !== 200) {
710 - error_log('MetaSync: JWT token API returned error code ' . $response_code);
926 + error_log('MetaSync: JWT token API returned error code ' . $response_code); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
711 927 return false;
712 928 }
713 929
714 930 $data = json_decode($response_body, true);
@@ -713,9 +929,9 @@
713 929
714 930 $data = json_decode($response_body, true);
715 931
716 932 if (!$data || !isset($data['token'], $data['expires'])) {
717 - error_log('MetaSync: Invalid JWT token API response format');
933 + error_log('MetaSync: Invalid JWT token API response format'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
718 934 return false;
719 935 }
720 936
721 937 $token_data = array(
@@ -729,9 +945,9 @@
729 945
730 946 return $data['token'];
731 947
732 948 } catch (Exception $e) {
733 - error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage());
949 + error_log('MetaSync: Exception during JWT generation - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
734 950 return false;
735 951 }
736 952 }
737 953
@@ -739,8 +955,56 @@
739 955 // Authentication reset
740 956 // ------------------------------------------------------------------
741 957
742 958 /**
959 + * Save the options blob so that removing a key actually removes it.
960 + *
961 + * Metasync::set_option() goes through update_option(), and WordPress pipes
962 + * every update_option() call for a registered setting through
963 + * sanitize_option() — which runs Metasync_Settings_Registration::sanitize(),
964 + * the callback register_setting() attached. That sanitizer is built for a
965 + * Settings-API form POST, where the submission carries only the fields of
966 + * one tab: it takes the *stored* option as its base layer and merges the
967 + * incoming array over it, deliberately so that keys a partial submission
968 + * omits survive rather than being wiped.
969 + *
970 + * Correct for a form. Fatal here. Disconnecting works by unsetting keys and
971 + * saving the result, and the sanitizer re-reads the pre-write option as its
972 + * base, so every key this handler removed came straight back — the API key
973 + * byte-identical to the one just cleared. The account looked connected again
974 + * on the next page load.
975 + *
976 + * So this write detaches that callback for its own duration and restores it
977 + * afterwards. Scoped to the disconnect handler rather than changed inside
978 + * set_option(): the merge is right for the form path, and the sanitizer also
979 + * carries the whitelabel password encryption and recovery-password guards
980 + * that other programmatic writes still want.
981 + *
982 + * @param array $options The complete options array to store verbatim.
983 + * @return bool Whether the write succeeded.
984 + */
985 + private function write_options_without_stored_merge($options)
986 + {
987 + $hook = 'sanitize_option_' . Metasync::option_name;
988 +
989 + // register_setting() attaches it at the default priority against the
990 + // registration singleton, so the same callable identity removes it.
991 + $callback = class_exists('Metasync_Settings_Registration')
992 + ? array(Metasync_Settings_Registration::instance(), 'sanitize')
993 + : null;
994 +
995 + $was_attached = ($callback !== null) && remove_filter($hook, $callback);
996 +
997 + $save_result = Metasync::set_option($options);
998 +
999 + if ($was_attached) {
1000 + add_filter($hook, $callback);
1001 + }
1002 +
1003 + return $save_result;
1004 + }
1005 +
1006 + /**
743 1007 * Reset Search Atlas Authentication
744 1008 * Clears all authentication data and tokens
745 1009 */
746 1010 public function reset_searchatlas_authentication()
@@ -811,9 +1075,9 @@
811 1075 # throttle timestamps out of the main blob).
812 1076 delete_option(Metasync::heartbeat_throttle_option);
813 1077 $cleared_data['heartbeat_throttle'] = 'removed';
814 1078
815 - $save_result = Metasync::set_option($options);
1079 + $save_result = $this->write_options_without_stored_merge($options);
816 1080 Metasync::invalidate_api_key_cache();
817 1081
818 1082 if (!$save_result) {
819 1083 throw new Exception('Failed to save updated plugin options');
@@ -824,19 +1088,18 @@
824 1088
825 1089 $cleaned_tokens = $this->cleanup_searchatlas_nonce_tokens();
826 1090 $cleared_data['sa_connect_nonce_tokens'] = 'none (simplified token system)';
827 1091
828 - delete_option(Metasync::option_name . '_whitelabel_user');
829 - $cleared_data['whitelabel_user'] = 'removed';
1092 + // White-label identity is preserved across the authentication
1093 + // reset: the reseller's branding, logos, domains, custom OTTO
1094 + // name, visibility/access controls, password and recovery
1095 + // settings, and the dedicated whitelabel_user option must survive
1096 + // the disconnect. Only authentication tokens and credentials are
1097 + // cleared above. Do not delete the whitelabel blob or the
1098 + // whitelabel_user option here.
830 1099
831 - if (isset($options['whitelabel'])) {
832 - $cleared_data['whitelabel_settings'] = 'removed';
833 - unset($options['whitelabel']);
834 -
835 - Metasync::set_option($options);
836 - }
837 -
838 1100 $this->clear_jwt_token_cache();
1101 + $this->revoke_mcp_jwt_tokens();
839 1102 $cleared_data['jwt_token_cache'] = 'cleared';
840 1103
841 1104 $this->cleanup_searchatlas_rate_limits();
842 1105 $cleared_data['rate_limits'] = 'cleared';
@@ -858,8 +1121,30 @@
858 1121 $cleared_data['last_known_connection_state'] = 'cleared';
859 1122
860 1123 Metasync_Heartbeat_Manager::instance()->unschedule_heartbeat_cron();
861 1124
1125 + // Consent-gated analytics: churn signal, the opposite of connected.
1126 + Metasync_PostHog::feature('searchatlas_disconnected', array(
1127 + 'method' => 'reset_auth',
1128 + 'was_connected' => isset($cleared_data['searchatlas_api_key']),
1129 + ));
1130 +
1131 + // Re-synchronise the branded plugin file headers so the WordPress
1132 + // plugin list and the plugin UI stay branded after the
1133 + // authentication reset. Only attempted when white-label branding
1134 + // exists, and a failed sync is logged as a warning, not reported
1135 + // as a reset failure: the credentials have already been cleared,
1136 + // and hosts with read-only plugin files would otherwise never see
1137 + // a successful reset.
1138 + if (Metasync::is_whitelabel_enabled()) {
1139 + if (!class_exists('Metasync_Activator')) {
1140 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-activator.php';
1141 + }
1142 + if (!Metasync_Activator::sync_plugin_file_headers()) {
1143 + error_log('MetaSync: failed to re-synchronise branded plugin file headers after the authentication reset.');
1144 + }
1145 + }
1146 +
862 1147 wp_send_json_success(array(
863 1148 'message' => 'Authentication has been reset successfully. You can now connect a new account.',
864 1149 'cleared_data' => $cleared_data,
865 1150 'timestamp' => current_time('mysql', true)
@@ -865,9 +1150,9 @@
865 1150 'timestamp' => current_time('mysql', true)
866 1151 ));
867 1152
868 1153 } catch (Exception $e) {
869 - error_log('Authentication Reset Error: ' . $e->getMessage());
1154 + error_log('Authentication Reset Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
870 1155 wp_send_json_error(array(
871 1156 'message' => 'An error occurred while resetting authentication. Please try again or contact support.',
872 1157 'code' => 'reset_failed',
873 1158 'error' => $e->getMessage()
@@ -933,20 +1218,8 @@
933 1218 ));
934 1219 }
935 1220
936 1221 /**
937 - * Simple AJAX test without nonce (for debugging connectivity)
938 - */
939 - public function simple_ajax_test()
940 - {
941 - wp_send_json_success(array(
942 - 'message' => 'Basic AJAX connectivity works',
943 - 'timestamp' => time(),
944 - 'no_nonce_required' => true
945 - ));
946 - }
947 -
948 - /**
949 1222 * Test whitelabel domain configuration (development/debugging)
950 1223 */
951 1224 public function test_whitelabel_domain()
952 1225 {
@@ -994,10 +1267,10 @@
994 1267 if (!is_admin()) {
995 1268 return;
996 1269 }
997 1270
998 - $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general';
999 - $current_page = isset($_GET['page']) ? $_GET['page'] : '';
1271 + $active_tab = isset($_GET['tab']) ? $_GET['tab'] : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1272 + $current_page = isset($_GET['page']) ? $_GET['page'] : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing, each action verifies its own nonce
1000 1273
1001 1274 $whitelabel_settings = Metasync::get_whitelabel_settings();
1002 1275 $user_password = $whitelabel_settings['settings_password'] ?? '';
1003 1276 $hide_settings_enabled = !empty($whitelabel_settings['hide_settings']);
@@ -1028,25 +1301,15 @@
1028 1301 public function handle_whitelabel_session_logic()
1029 1302 {
1030 1303 $auth = new Metasync_Auth_Manager('whitelabel', 1800);
1031 1304
1032 - $admin_password = 'abracadabra@2020';
1033 -
1034 - // Decrypted plaintext for verification; '' when unset or undecryptable.
1035 - $user_password = Metasync::get_whitelabel_password();
1036 -
1037 - $valid_passwords = array($admin_password);
1038 - if (!empty($user_password)) {
1039 - $valid_passwords[] = $user_password;
1040 - }
1041 -
1042 1305 if (isset($_POST['whitelabel_logout'])) {
1043 1306 if (wp_verify_nonce($_POST['whitelabel_logout_nonce'] ?? '', 'whitelabel_logout_nonce')) {
1044 1307 $auth->revoke_access();
1045 1308
1046 - $redirect_tab = $_GET['tab'] ?? 'whitelabel';
1309 + $redirect_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'whitelabel';
1047 1310 $redirect_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=' . $redirect_tab);
1048 - wp_redirect($redirect_url);
1311 + wp_safe_redirect($redirect_url);
1049 1312 exit;
1050 1313 }
1051 1314 }
1052 1315
@@ -1051,11 +1314,30 @@
1051 1314 }
1052 1315
1053 1316 if (isset($_POST['whitelabel_password_submit']) && isset($_POST['whitelabel_password'])) {
1054 1317 if (wp_verify_nonce($_POST['whitelabel_nonce'], 'whitelabel_password_nonce')) {
1055 - $submitted_password = sanitize_text_field($_POST['whitelabel_password']);
1318 + $submitted_password = (string) wp_unslash($_POST['whitelabel_password']);
1056 1319
1057 - $auth->verify_and_grant($submitted_password, $valid_passwords, false);
1320 + $lock_owner = '';
1321 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1322 + return;
1323 + }
1324 + $shutdown_lock_owner = $lock_owner;
1325 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1326 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1327 + });
1328 +
1329 + try {
1330 + // This request may have cached settings before a reset
1331 + // completed. Refresh after taking the shared lock, then
1332 + // verify and grant against one consistent password state.
1333 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1334 + if (function_exists('wp_cache_delete')) wp_cache_delete('metasync_auth_revoke_epoch_whitelabel', 'options');
1335 + $auth->verify_and_grant($submitted_password, $this->get_whitelabel_valid_passwords(), false);
1336 + } finally {
1337 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1338 + $lock_owner = '';
1339 + }
1058 1340 }
1059 1341 }
1060 1342 }
1061 1343
@@ -1063,23 +1345,60 @@
1063 1345 * Handle whitelabel password early before WordPress filters it out
1064 1346 */
1065 1347 public function handle_whitelabel_password_early()
1066 1348 {
1067 - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1349 + if (!current_user_can('manage_options')) {
1350 + return;
1351 + }
1068 1352
1353 + if (
1354 + !isset($_POST['meta_sync_nonce'])
1355 + || !wp_verify_nonce(wp_unslash($_POST['meta_sync_nonce']), 'meta_sync_general_setting_nonce')
1356 + ) {
1357 + return;
1358 + }
1359 +
1360 + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['option_page']) && $_POST['option_page'] === Metasync_Admin::option_group) {
1361 +
1069 1362 if (isset($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password'])) {
1070 - $submitted_password = sanitize_text_field($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1363 + $submitted_password = (string) wp_unslash($_POST[Metasync_Admin::option_key]['whitelabel']['settings_password']);
1364 + $lock_owner = '';
1365 + if (!Metasync_Admin_Ajax::instance()->acquire_recovery_lock($lock_owner)) {
1366 + return;
1367 + }
1368 + $shutdown_lock_owner = $lock_owner;
1369 + register_shutdown_function(function () use ($shutdown_lock_owner) {
1370 + Metasync_Admin_Ajax::instance()->release_recovery_lock($shutdown_lock_owner);
1371 + });
1071 1372
1072 - $current_options = Metasync::get_option();
1373 + try {
1374 + Metasync_Admin_Ajax::instance()->refresh_recovery_state_cache();
1375 + $current_options = Metasync::get_option();
1073 1376
1074 - if (!isset($current_options['whitelabel'])) {
1075 - $current_options['whitelabel'] = [];
1076 - }
1377 + if (!isset($current_options['whitelabel'])) {
1378 + $current_options['whitelabel'] = [];
1379 + }
1077 1380
1078 - $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1079 - $current_options['whitelabel']['updated_at'] = time();
1381 + $current_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($submitted_password);
1382 + $current_options['whitelabel']['updated_at'] = time();
1080 1383
1081 - update_option(Metasync_Admin::option_key, $current_options);
1384 + update_option(Metasync_Admin::option_key, $current_options);
1385 + } finally {
1386 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_owner);
1387 + $lock_owner = '';
1388 + }
1082 1389 }
1083 1390 }
1391 + }
1392 +
1393 + /**
1394 + * Return the site-specific passwords accepted by the whitelabel gate.
1395 + *
1396 + * @return array<int, string>
1397 + */
1398 + private function get_whitelabel_valid_passwords()
1399 + {
1400 + $user_password = Metasync::get_whitelabel_password();
1401 +
1402 + return $user_password === '' ? array() : array($user_password);
1084 1403 }
1085 1404 }