| @@ -18,8 +18,105 @@ | ||
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | private function __construct() {} |
| 21 | 21 | |
| 22 | + /** | |
| 23 | + * Discard request-local recovery state after acquiring the shared lock. | |
| 24 | + * | |
| 25 | + * A request can wait for another recovery/settings request while its | |
| 26 | + * WordPress option or transient cache still contains the pre-lock state. | |
| 27 | + * Refreshing here makes every decision under the lock use current storage. | |
| 28 | + */ | |
| 29 | + public function refresh_recovery_state_cache($transient_key = '') | |
| 30 | + { | |
| 31 | + if (!function_exists('wp_cache_delete')) { | |
| 32 | + return; | |
| 33 | + } | |
| 34 | + | |
| 35 | + wp_cache_delete('alloptions', 'options'); | |
| 36 | + wp_cache_delete(Metasync::option_name, 'options'); | |
| 37 | + wp_cache_delete('metasync_pw_reset_generation', 'options'); | |
| 38 | + | |
| 39 | + if ($transient_key !== '') { | |
| 40 | + wp_cache_delete($transient_key, 'transient'); | |
| 41 | + wp_cache_delete('_transient_' . $transient_key, 'options'); | |
| 42 | + wp_cache_delete('_transient_timeout_' . $transient_key, 'options'); | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + public function acquire_recovery_lock(&$owner) | |
| 47 | + { | |
| 48 | + $owner = bin2hex(random_bytes(16)); | |
| 49 | + $key = 'metasync_pw_recovery_rate_lock'; | |
| 50 | + if (add_option($key, array('owner' => $owner, 'time' => time()), '', false)) return true; | |
| 51 | + $held = get_option($key, array()); | |
| 52 | + $held_time = is_array($held) ? (int) ($held['time'] ?? 0) : (int) $held; | |
| 53 | + // Settings requests may run for up to five minutes. Keep the lease | |
| 54 | + // comfortably beyond that bound; shutdown cleanup releases it | |
| 55 | + // promptly during normal completion and handled failures. | |
| 56 | + if ($held_time > 0 && time() - $held_time > 900) { | |
| 57 | + $replacement = array('owner' => $owner, 'time' => time()); | |
| 58 | + global $wpdb; | |
| 59 | + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) { | |
| 60 | + $serialize = function ($value) { return function_exists('maybe_serialize') ? maybe_serialize($value) : serialize($value); }; | |
| 61 | + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive | |
| 62 | + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", | |
| 63 | + $serialize($replacement), $key, $serialize($held) | |
| 64 | + )); | |
| 65 | + if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options'); | |
| 66 | + } else { | |
| 67 | + update_option($key, $replacement, false); | |
| 68 | + } | |
| 69 | + $claimed = get_option($key, array()); | |
| 70 | + return is_array($claimed) && isset($claimed['owner']) && hash_equals((string) $claimed['owner'], (string) $owner); | |
| 71 | + } | |
| 72 | + return false; | |
| 73 | + } | |
| 74 | + | |
| 75 | + public function release_recovery_lock($owner) | |
| 76 | + { | |
| 77 | + $key = 'metasync_pw_recovery_rate_lock'; | |
| 78 | + $held = get_option($key, array()); | |
| 79 | + if (is_array($held) && isset($held['owner']) && hash_equals((string) $held['owner'], (string) $owner)) { | |
| 80 | + global $wpdb; | |
| 81 | + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) { | |
| 82 | + $serialized = function_exists('maybe_serialize') ? maybe_serialize($held) : serialize($held); | |
| 83 | + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive | |
| 84 | + "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", | |
| 85 | + $key, | |
| 86 | + $serialized | |
| 87 | + )); | |
| 88 | + if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options'); | |
| 89 | + return; | |
| 90 | + } | |
| 91 | + | |
| 92 | + // Test/bootstrap fallback. Production uses the owner-checked SQL | |
| 93 | + // delete above so expired-owner cleanup cannot remove a new lock. | |
| 94 | + delete_option($key); | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + private function persist_recovery_password($encrypted) | |
| 99 | + { | |
| 100 | + for ($attempt = 0; $attempt < 3; $attempt++) { | |
| 101 | + $options = Metasync::get_option(); | |
| 102 | + if (!is_array($options)) $options = array(); | |
| 103 | + if (!isset($options['whitelabel']) || !is_array($options['whitelabel'])) $options['whitelabel'] = array(); | |
| 104 | + $options['whitelabel']['settings_password'] = $encrypted; | |
| 105 | + Metasync_Settings_Registration::authorize_recovery_password_write(true); | |
| 106 | + try { | |
| 107 | + $saved = Metasync::set_option($options); | |
| 108 | + } finally { | |
| 109 | + Metasync_Settings_Registration::authorize_recovery_password_write(false); | |
| 110 | + } | |
| 111 | + if ($saved) { | |
| 112 | + $stored = Metasync::get_option('whitelabel'); | |
| 113 | + if (is_array($stored) && isset($stored['settings_password']) && hash_equals((string) $encrypted, (string) $stored['settings_password'])) return true; | |
| 114 | + } | |
| 115 | + } | |
| 116 | + return false; | |
| 117 | + } | |
| 118 | + | |
| 22 | 119 | private function get_db_redirection() |
| 23 | 120 | { |
| 24 | 121 | if (null === $this->db_redirection) { |
| 25 | 122 | $this->db_redirection = new Metasync_Redirection_Database(); |
| @@ -30,9 +127,9 @@ | ||
| 30 | 127 | public function ajax_import_external_data() |
| 31 | 128 | { |
| 32 | 129 | $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time'); |
| 33 | 130 | if (function_exists('set_time_limit')) { |
| 34 | - @set_time_limit($execution_time); | |
| 131 | + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API | |
| 35 | 132 | } |
| 36 | 133 | |
| 37 | 134 | Metasync_Settings_Fields::instance()->apply_memory_limit(); |
| 38 | 135 | |
| @@ -41,10 +138,11 @@ | ||
| 41 | 138 | if (!Metasync::current_user_has_plugin_access()) { |
| 42 | 139 | wp_send_json_error(['message' => 'Insufficient permissions.']); |
| 43 | 140 | } |
| 44 | 141 | |
| 45 | - $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : ''; | |
| 46 | - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; | |
| 142 | + $type = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : ''; | |
| 143 | + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : ''; | |
| 144 | + $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0; | |
| 47 | 145 | |
| 48 | 146 | if (empty($type) || empty($plugin)) { |
| 49 | 147 | wp_send_json_error(['message' => 'Missing required parameters.']); |
| 50 | 148 | } |
| @@ -63,9 +161,9 @@ | ||
| 63 | 161 | case 'robots': |
| 64 | 162 | $result = $importer->import_robots($plugin); |
| 65 | 163 | break; |
| 66 | 164 | case 'indexation': |
| 67 | - $result = $importer->import_indexation($plugin); | |
| 165 | + $result = $importer->import_indexation($plugin, ['batch_size' => 50, 'offset' => $offset]); | |
| 68 | 166 | break; |
| 69 | 167 | case 'schema': |
| 70 | 168 | $result = $importer->import_schema($plugin); |
| 71 | 169 | break; |
| @@ -70,8 +168,17 @@ | ||
| 70 | 168 | $result = $importer->import_schema($plugin); |
| 71 | 169 | break; |
| 72 | 170 | } |
| 73 | 171 | |
| 172 | + // A schema import that only found already-migrated or unsupported | |
| 173 | + // records is an accurate report, not a failure — keep the breakdown | |
| 174 | + // with the response so the UI can show each category separately. | |
| 175 | + if (isset($result['breakdown'])) { | |
| 176 | + $result['breakdown'] = array_map('intval', (array) $result['breakdown']); | |
| 177 | + } | |
| 178 | + | |
| 179 | + Metasync_PostHog::import_batch($plugin, $type, $result, $offset); | |
| 180 | + | |
| 74 | 181 | if ($result['success']) { |
| 75 | 182 | wp_send_json_success($result); |
| 76 | 183 | } else { |
| 77 | 184 | wp_send_json_error($result); |
| @@ -81,9 +188,9 @@ | ||
| 81 | 188 | public function ajax_import_seo_metadata() |
| 82 | 189 | { |
| 83 | 190 | $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time'); |
| 84 | 191 | if (function_exists('set_time_limit')) { |
| 85 | - @set_time_limit($execution_time); | |
| 192 | + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API | |
| 86 | 193 | } |
| 87 | 194 | |
| 88 | 195 | Metasync_Settings_Fields::instance()->apply_memory_limit(); |
| 89 | 196 | |
| @@ -92,11 +199,13 @@ | ||
| 92 | 199 | if (!Metasync::current_user_has_plugin_access()) { |
| 93 | 200 | wp_send_json_error(['message' => 'Insufficient permissions.']); |
| 94 | 201 | } |
| 95 | 202 | |
| 96 | - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; | |
| 203 | + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : ''; | |
| 97 | 204 | $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true; |
| 98 | 205 | $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true; |
| 206 | + $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true; | |
| 207 | + $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true; | |
| 99 | 208 | $overwrite_existing = isset($_POST['overwrite_existing']) ? (bool) intval($_POST['overwrite_existing']) : false; |
| 100 | 209 | $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0; |
| 101 | 210 | |
| 102 | 211 | if (empty($plugin)) { |
| @@ -108,8 +217,10 @@ | ||
| 108 | 217 | |
| 109 | 218 | $options = [ |
| 110 | 219 | 'import_titles' => $import_titles, |
| 111 | 220 | 'import_descriptions' => $import_descriptions, |
| 221 | + 'import_social_text' => $import_social_text, | |
| 222 | + 'import_social_images' => $import_social_images, | |
| 112 | 223 | 'overwrite_existing' => $overwrite_existing, |
| 113 | 224 | 'batch_size' => 50, |
| 114 | 225 | 'offset' => $offset |
| 115 | 226 | ]; |
| @@ -115,8 +226,12 @@ | ||
| 115 | 226 | ]; |
| 116 | 227 | |
| 117 | 228 | $result = $importer->import_seo_metadata($plugin, $options); |
| 118 | 229 | |
| 230 | + Metasync_PostHog::import_batch($plugin, 'seo_metadata', $result, $offset, array( | |
| 231 | + 'overwrite_existing' => $overwrite_existing, | |
| 232 | + )); | |
| 233 | + | |
| 119 | 234 | if ($result['success']) { |
| 120 | 235 | wp_send_json_success($result); |
| 121 | 236 | } else { |
| 122 | 237 | wp_send_json_error($result); |
| @@ -135,10 +250,14 @@ | ||
| 135 | 250 | # use the existing apikey for backward compatibility |
| 136 | 251 | $general_options = Metasync::get_option('general') ?? []; |
| 137 | 252 | $token = $general_options['apikey'] ?? null; |
| 138 | 253 | |
| 254 | + # declare the call context explicitly so only the Settings | |
| 255 | + # "Sync Now" button consumes/stamps the 5-minute manual cooldown. | |
| 256 | + $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN); | |
| 257 | + | |
| 139 | 258 | # get the response |
| 140 | - $response = $sync_request->SyncCustomerParams($token); | |
| 259 | + $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual'); | |
| 141 | 260 | |
| 142 | 261 | // Check if response is a throttling error object |
| 143 | 262 | if (is_object($response) && isset($response->throttled) && $response->throttled === true) { |
| 144 | 263 | wp_send_json($response); |
| @@ -213,9 +332,9 @@ | ||
| 213 | 332 | $step = isset($_POST['step']) ? intval($_POST['step']) : 0; |
| 214 | 333 | $raw_data = isset($_POST['data']) ? $_POST['data'] : array(); |
| 215 | 334 | |
| 216 | 335 | // Allowlist top-level wizard data keys to prevent mass assignment |
| 217 | - $allowed_wizard_keys = array('verification', 'seo_settings', 'schema'); | |
| 336 | + $allowed_wizard_keys = array('verification', 'seo_settings', 'schema', 'analytics'); | |
| 218 | 337 | $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array(); |
| 219 | 338 | |
| 220 | 339 | $options = get_option('metasync_options', array()); |
| 221 | 340 | |
| @@ -245,8 +364,13 @@ | ||
| 245 | 364 | $options['general']['enable_schema_markup'] = $data['schema']['enabled']; |
| 246 | 365 | $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']); |
| 247 | 366 | } |
| 248 | 367 | |
| 368 | + if (isset($data['analytics']) && is_array($data['analytics'])) { | |
| 369 | + update_option('metasync_analytics_opt_in', !empty($data['analytics']['enabled']) ? 'yes' : 'no'); | |
| 370 | + update_option('metasync_analytics_consent_explicit', 'yes'); | |
| 371 | + } | |
| 372 | + | |
| 249 | 373 | update_option('metasync_options', $options); |
| 250 | 374 | |
| 251 | 375 | wp_send_json_success(array('message' => 'Progress saved')); |
| 252 | 376 | } |
| @@ -268,8 +392,14 @@ | ||
| 268 | 392 | |
| 269 | 393 | $user_id = get_current_user_id(); |
| 270 | 394 | delete_transient("metasync_wizard_state_{$user_id}"); |
| 271 | 395 | |
| 396 | + // Consent-gated product analytics: setup funnel completion. | |
| 397 | + $general_settings = Metasync::get_option('general'); | |
| 398 | + Metasync_PostHog::feature('setup_wizard_completed', array( | |
| 399 | + 'connected' => !empty($general_settings['searchatlas_api_key']), | |
| 400 | + )); | |
| 401 | + | |
| 272 | 402 | wp_send_json_success(array('message' => 'Wizard completed')); |
| 273 | 403 | } |
| 274 | 404 | |
| 275 | 405 | public function ajax_validate_robots() |
| @@ -407,8 +537,48 @@ | ||
| 407 | 537 | } |
| 408 | 538 | } |
| 409 | 539 | } |
| 410 | 540 | |
| 541 | + public function ajax_get_robots_backups() | |
| 542 | + { | |
| 543 | + if (!Metasync::current_user_has_plugin_access()) { | |
| 544 | + wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync'))); | |
| 545 | + return; | |
| 546 | + } | |
| 547 | + | |
| 548 | + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_get_robots_backups')) { | |
| 549 | + wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync'))); | |
| 550 | + return; | |
| 551 | + } | |
| 552 | + | |
| 553 | + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php'; | |
| 554 | + $robots_txt = Metasync_Robots_Txt::get_instance(); | |
| 555 | + | |
| 556 | + $backup_per_page = Metasync_Robots_Txt_Database::BACKUPS_PER_PAGE; | |
| 557 | + $backup_total = $robots_txt->get_backup_count(); | |
| 558 | + $backup_total_pages = max(1, (int) ceil($backup_total / $backup_per_page)); | |
| 559 | + | |
| 560 | + $backup_page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1; | |
| 561 | + if ($backup_page > $backup_total_pages) { | |
| 562 | + $backup_page = $backup_total_pages; | |
| 563 | + } | |
| 564 | + | |
| 565 | + $offset = ($backup_page - 1) * $backup_per_page; | |
| 566 | + $backups = $robots_txt->get_backup_history($backup_per_page, $offset); | |
| 567 | + | |
| 568 | + // Render the shared list + pagination partial to a string. | |
| 569 | + ob_start(); | |
| 570 | + require plugin_dir_path(dirname(__FILE__)) . 'robots-txt/views/backup-history.php'; | |
| 571 | + $html = ob_get_clean(); | |
| 572 | + | |
| 573 | + wp_send_json_success(array( | |
| 574 | + 'html' => $html, | |
| 575 | + 'total' => $backup_total, | |
| 576 | + 'page' => $backup_page, | |
| 577 | + 'total_pages' => $backup_total_pages, | |
| 578 | + )); | |
| 579 | + } | |
| 580 | + | |
| 411 | 581 | public function ajax_create_redirect_from_404() |
| 412 | 582 | { |
| 413 | 583 | if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) { |
| 414 | 584 | wp_die('Security check failed'); |
| @@ -417,10 +587,10 @@ | ||
| 417 | 587 | if (!Metasync::current_user_has_plugin_access()) { |
| 418 | 588 | wp_die('Insufficient permissions'); |
| 419 | 589 | } |
| 420 | 590 | |
| 421 | - $uri = sanitize_text_field($_POST['uri']); | |
| 422 | - $redirect_url = sanitize_url($_POST['redirect_url']); | |
| 591 | + $uri = sanitize_text_field(wp_unslash($_POST['uri'])); | |
| 592 | + $redirect_url = sanitize_url(wp_unslash($_POST['redirect_url'])); | |
| 423 | 593 | |
| 424 | 594 | if (empty($uri) || empty($redirect_url)) { |
| 425 | 595 | wp_send_json_error('Missing required parameters'); |
| 426 | 596 | } |
| @@ -438,91 +608,33 @@ | ||
| 438 | 608 | wp_send_json_error('Failed to create redirect'); |
| 439 | 609 | } |
| 440 | 610 | } |
| 441 | 611 | |
| 612 | + /** | |
| 613 | + * Manual "Host Blocking Test" — GET leg. | |
| 614 | + * | |
| 615 | + * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the | |
| 616 | + * automatic activation/weekly check share one implementation. | |
| 617 | + */ | |
| 442 | 618 | public function ajax_test_host_blocking_get() |
| 443 | 619 | { |
| 444 | - check_ajax_referer('metasync_nonce', 'nonce'); | |
| 445 | - if (!Metasync::current_user_has_plugin_access()) { | |
| 446 | - wp_send_json_error('Insufficient permissions'); | |
| 447 | - return; | |
| 448 | - } | |
| 620 | + $this->send_host_blocking_result('GET'); | |
| 621 | + } | |
| 449 | 622 | |
| 450 | - $endpoint = 'https://wp-check.searchatlas.com/ping'; | |
| 451 | - $start_time = microtime(true); | |
| 452 | - | |
| 453 | - $response = wp_remote_get($endpoint, array( | |
| 454 | - 'timeout' => 30, | |
| 455 | - 'user-agent' => 'MetaSync Plugin Host Test', | |
| 456 | - 'headers' => array( | |
| 457 | - 'Accept' => 'application/json', | |
| 458 | - 'Content-Type' => 'application/json', | |
| 459 | - 'Origin' => home_url(), | |
| 460 | - 'Referer' => admin_url(), | |
| 461 | - 'X-WordPress-Site' => home_url() | |
| 462 | - ) | |
| 463 | - )); | |
| 464 | - | |
| 465 | - $end_time = microtime(true); | |
| 466 | - $response_time = round(($end_time - $start_time) * 1000, 2); | |
| 467 | - | |
| 468 | - if (is_wp_error($response)) { | |
| 469 | - wp_send_json_success(array( | |
| 470 | - 'method' => 'GET', | |
| 471 | - 'status' => 'error', | |
| 472 | - 'response_time' => $response_time . 'ms', | |
| 473 | - 'error' => $response->get_error_message(), | |
| 474 | - 'blocked' => true, | |
| 475 | - 'details' => 'Request failed - possible blocking detected' | |
| 476 | - )); | |
| 477 | - } else { | |
| 478 | - $status_code = wp_remote_retrieve_response_code($response); | |
| 479 | - $body = wp_remote_retrieve_body($response); | |
| 480 | - $headers = wp_remote_retrieve_headers($response); | |
| 481 | - | |
| 482 | - $is_blocked = false; | |
| 483 | - $status_text = 'success'; | |
| 484 | - $details = 'GET request completed successfully'; | |
| 485 | - $parsed_response = null; | |
| 486 | - | |
| 487 | - if ($status_code === 200) { | |
| 488 | - $parsed_response = json_decode($body, true); | |
| 489 | - | |
| 490 | - if ($parsed_response && isset($parsed_response['results']['get'])) { | |
| 491 | - $get_result = $parsed_response['results']['get']; | |
| 492 | - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null; | |
| 493 | - | |
| 494 | - if ($get_status_code !== 200) { | |
| 495 | - $is_blocked = true; | |
| 496 | - $status_text = 'error'; | |
| 497 | - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected"; | |
| 498 | - } | |
| 499 | - } else { | |
| 500 | - $is_blocked = true; | |
| 501 | - $status_text = 'error'; | |
| 502 | - $details = 'Unable to parse response structure - possible blocking or endpoint issue'; | |
| 503 | - } | |
| 504 | - } else { | |
| 505 | - $is_blocked = true; | |
| 506 | - $status_text = 'error'; | |
| 507 | - $details = "External endpoint returned status code {$status_code} - possible blocking detected"; | |
| 508 | - } | |
| 509 | - | |
| 510 | - wp_send_json_success(array( | |
| 511 | - 'method' => 'GET', | |
| 512 | - 'status' => $status_text, | |
| 513 | - 'response_time' => $response_time . 'ms', | |
| 514 | - 'status_code' => $status_code, | |
| 515 | - 'body' => $body, | |
| 516 | - 'headers' => $headers->getAll(), | |
| 517 | - 'blocked' => $is_blocked, | |
| 518 | - 'details' => $details, | |
| 519 | - 'parsed_response' => $parsed_response | |
| 520 | - )); | |
| 521 | - } | |
| 623 | + /** | |
| 624 | + * Manual "Host Blocking Test" — POST leg. | |
| 625 | + */ | |
| 626 | + public function ajax_test_host_blocking_post() | |
| 627 | + { | |
| 628 | + $this->send_host_blocking_result('POST'); | |
| 522 | 629 | } |
| 523 | 630 | |
| 524 | - public function ajax_test_host_blocking_post() | |
| 631 | + /** | |
| 632 | + * Shared auth + response wrapper for both manual host blocking legs. | |
| 633 | + * | |
| 634 | + * @param string $method 'GET' or 'POST'. | |
| 635 | + */ | |
| 636 | + private function send_host_blocking_result($method) | |
| 525 | 637 | { |
| 526 | 638 | check_ajax_referer('metasync_nonce', 'nonce'); |
| 527 | 639 | if (!Metasync::current_user_has_plugin_access()) { |
| 528 | 640 | wp_send_json_error('Insufficient permissions'); |
| @@ -528,90 +640,9 @@ | ||
| 528 | 640 | wp_send_json_error('Insufficient permissions'); |
| 529 | 641 | return; |
| 530 | 642 | } |
| 531 | 643 | |
| 532 | - $endpoint = 'https://wp-check.searchatlas.com/ping'; | |
| 533 | - $start_time = microtime(true); | |
| 534 | - | |
| 535 | - $test_data = array( | |
| 536 | - 'test' => 'host_blocking_test', | |
| 537 | - 'timestamp' => current_time('mysql'), | |
| 538 | - 'source' => 'metasync_plugin', | |
| 539 | - 'method' => 'POST' | |
| 540 | - ); | |
| 541 | - | |
| 542 | - $response = wp_remote_post($endpoint, array( | |
| 543 | - 'timeout' => 30, | |
| 544 | - 'user-agent' => 'MetaSync Plugin Host Test', | |
| 545 | - 'headers' => array( | |
| 546 | - 'Accept' => 'application/json', | |
| 547 | - 'Content-Type' => 'application/json', | |
| 548 | - 'Origin' => home_url(), | |
| 549 | - 'Referer' => admin_url(), | |
| 550 | - 'X-WordPress-Site' => home_url() | |
| 551 | - ), | |
| 552 | - 'body' => json_encode($test_data) | |
| 553 | - )); | |
| 554 | - | |
| 555 | - $end_time = microtime(true); | |
| 556 | - $response_time = round(($end_time - $start_time) * 1000, 2); | |
| 557 | - | |
| 558 | - if (is_wp_error($response)) { | |
| 559 | - wp_send_json_success(array( | |
| 560 | - 'method' => 'POST', | |
| 561 | - 'status' => 'error', | |
| 562 | - 'response_time' => $response_time . 'ms', | |
| 563 | - 'error' => $response->get_error_message(), | |
| 564 | - 'blocked' => true, | |
| 565 | - 'details' => 'Request failed - possible blocking detected', | |
| 566 | - 'sent_data' => $test_data | |
| 567 | - )); | |
| 568 | - } else { | |
| 569 | - $status_code = wp_remote_retrieve_response_code($response); | |
| 570 | - $body = wp_remote_retrieve_body($response); | |
| 571 | - $headers = wp_remote_retrieve_headers($response); | |
| 572 | - | |
| 573 | - $is_blocked = false; | |
| 574 | - $status_text = 'success'; | |
| 575 | - $details = 'POST request completed successfully'; | |
| 576 | - $parsed_response = null; | |
| 577 | - | |
| 578 | - if ($status_code === 200) { | |
| 579 | - $parsed_response = json_decode($body, true); | |
| 580 | - | |
| 581 | - if ($parsed_response && isset($parsed_response['results']['post'])) { | |
| 582 | - $post_result = $parsed_response['results']['post']; | |
| 583 | - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null; | |
| 584 | - | |
| 585 | - if ($post_status_code !== 200) { | |
| 586 | - $is_blocked = true; | |
| 587 | - $status_text = 'error'; | |
| 588 | - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected"; | |
| 589 | - } | |
| 590 | - } else { | |
| 591 | - $is_blocked = true; | |
| 592 | - $status_text = 'error'; | |
| 593 | - $details = 'Unable to parse response structure - possible blocking or endpoint issue'; | |
| 594 | - } | |
| 595 | - } else { | |
| 596 | - $is_blocked = true; | |
| 597 | - $status_text = 'error'; | |
| 598 | - $details = "External endpoint returned status code {$status_code} - possible blocking detected"; | |
| 599 | - } | |
| 600 | - | |
| 601 | - wp_send_json_success(array( | |
| 602 | - 'method' => 'POST', | |
| 603 | - 'status' => $status_text, | |
| 604 | - 'response_time' => $response_time . 'ms', | |
| 605 | - 'status_code' => $status_code, | |
| 606 | - 'body' => $body, | |
| 607 | - 'headers' => $headers->getAll(), | |
| 608 | - 'blocked' => $is_blocked, | |
| 609 | - 'details' => $details, | |
| 610 | - 'sent_data' => $test_data, | |
| 611 | - 'parsed_response' => $parsed_response | |
| 612 | - )); | |
| 613 | - } | |
| 644 | + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method)); | |
| 614 | 645 | } |
| 615 | 646 | |
| 616 | 647 | public function execute_transient_cleanup() |
| 617 | 648 | { |
| @@ -628,9 +659,9 @@ | ||
| 628 | 659 | try { |
| 629 | 660 | delete_expired_transients(true); |
| 630 | 661 | $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress'; |
| 631 | 662 | |
| 632 | - $plugin_transients = $wpdb->get_results( | |
| 663 | + $plugin_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 | |
| 633 | 664 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'", |
| 634 | 665 | ARRAY_A |
| 635 | 666 | ); |
| 636 | 667 | |
| @@ -639,9 +670,9 @@ | ||
| 639 | 670 | delete_transient($transient_name); |
| 640 | 671 | $cleanup_stats['plugin_transients']++; |
| 641 | 672 | } |
| 642 | 673 | |
| 643 | - $rate_limit_transients = $wpdb->get_results( | |
| 674 | + $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 | |
| 644 | 675 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'", |
| 645 | 676 | ARRAY_A |
| 646 | 677 | ); |
| 647 | 678 | |
| @@ -650,9 +681,9 @@ | ||
| 650 | 681 | delete_transient($transient_name); |
| 651 | 682 | $cleanup_stats['rate_limit_transients']++; |
| 652 | 683 | } |
| 653 | 684 | |
| 654 | - $telemetry_transients = $wpdb->get_results( | |
| 685 | + $telemetry_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 | |
| 655 | 686 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'", |
| 656 | 687 | ARRAY_A |
| 657 | 688 | ); |
| 658 | 689 | |
| @@ -661,9 +692,9 @@ | ||
| 661 | 692 | delete_transient($transient_name); |
| 662 | 693 | $cleanup_stats['telemetry_transients']++; |
| 663 | 694 | } |
| 664 | 695 | |
| 665 | - $sa_connect_success_transients = $wpdb->get_results( | |
| 696 | + $sa_connect_success_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 | |
| 666 | 697 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'", |
| 667 | 698 | ARRAY_A |
| 668 | 699 | ); |
| 669 | 700 | |
| @@ -674,17 +705,50 @@ | ||
| 674 | 705 | } |
| 675 | 706 | |
| 676 | 707 | $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2); |
| 677 | 708 | $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ? |
| 678 | - date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A'; | |
| 709 | + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A'; | |
| 679 | 710 | |
| 680 | - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats)); | |
| 681 | - | |
| 682 | 711 | } catch (Exception $e) { |
| 683 | - error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); | |
| 712 | + error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 684 | 713 | } |
| 685 | 714 | } |
| 686 | 715 | |
| 716 | + /** | |
| 717 | + * Collect the locally stored white-label settings for an issue-report payload. | |
| 718 | + * | |
| 719 | + * The values are attached to the Sentry event so support can tell from the | |
| 720 | + * event alone whether the reporting site is white-labeled. Local options | |
| 721 | + * can be stale — the reply-drafting process must still cross-check the | |
| 722 | + * backend account record before sending. | |
| 723 | + * | |
| 724 | + * Static and public so the unit suite can exercise the reads directly; the | |
| 725 | + * AJAX handler itself dies on wp_send_json_* before returning. | |
| 726 | + * | |
| 727 | + * @return array Keys: is_whitelabel, whitelabel_brand_name, whitelabel_support_email, site_url. | |
| 728 | + */ | |
| 729 | + public static function build_issue_report_whitelabel_context() | |
| 730 | + { | |
| 731 | + $whitelabel_settings = Metasync::get_whitelabel_settings(); | |
| 732 | + if (!is_array($whitelabel_settings)) { | |
| 733 | + $whitelabel_settings = array(); | |
| 734 | + } | |
| 735 | + $whitelabel_support_email = ''; | |
| 736 | + foreach (array('support_email_override', 'support_email', 'recovery_email') as $email_key) { | |
| 737 | + if (!empty($whitelabel_settings[$email_key])) { | |
| 738 | + $whitelabel_support_email = sanitize_email($whitelabel_settings[$email_key]); | |
| 739 | + break; | |
| 740 | + } | |
| 741 | + } | |
| 742 | + return array( | |
| 743 | + 'is_whitelabel' => Metasync::is_whitelabel_enabled(), | |
| 744 | + 'whitelabel_brand_name' => !empty($whitelabel_settings['company_name']) | |
| 745 | + ? sanitize_text_field($whitelabel_settings['company_name']) : '', | |
| 746 | + 'whitelabel_support_email' => $whitelabel_support_email, | |
| 747 | + 'site_url' => esc_url_raw(home_url()), | |
| 748 | + ); | |
| 749 | + } | |
| 750 | + | |
| 687 | 751 | public function ajax_submit_issue_report() |
| 688 | 752 | { |
| 689 | 753 | try { |
| 690 | 754 | # Verify nonce |
| @@ -755,8 +819,11 @@ | ||
| 755 | 819 | ); |
| 756 | 820 | } |
| 757 | 821 | } |
| 758 | 822 | |
| 823 | + # Read the locally stored white-label settings for the report payload. | |
| 824 | + $whitelabel_context = self::build_issue_report_whitelabel_context(); | |
| 825 | + | |
| 759 | 826 | # Get general options (same way as used throughout the plugin) |
| 760 | 827 | $general_options = Metasync::get_option('general'); |
| 761 | 828 | if (!is_array($general_options)) { |
| 762 | 829 | $general_options = array(); |
| @@ -811,14 +878,18 @@ | ||
| 811 | 878 | # Check if Sentry feedback function exists |
| 812 | 879 | if (!function_exists('metasync_sentry_capture_feedback')) { |
| 813 | 880 | # Log warning if function doesn't exist |
| 814 | 881 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 815 | - error_log('MetaSync: Sentry feedback function not available for report submission.'); | |
| 882 | + error_log('MetaSync: Sentry feedback function not available for report submission.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 816 | 883 | } |
| 817 | 884 | } else { |
| 818 | 885 | $feedback_data = array( |
| 819 | 886 | 'message' => $issue_message, |
| 820 | - 'severity' => $issue_severity | |
| 887 | + 'severity' => $issue_severity, | |
| 888 | + 'is_whitelabel' => $whitelabel_context['is_whitelabel'], | |
| 889 | + 'whitelabel_brand_name' => $whitelabel_context['whitelabel_brand_name'], | |
| 890 | + 'whitelabel_support_email' => $whitelabel_context['whitelabel_support_email'], | |
| 891 | + 'site_url' => $whitelabel_context['site_url'] | |
| 821 | 892 | ); |
| 822 | 893 | |
| 823 | 894 | # Add user information if requested |
| 824 | 895 | if ($include_user_info) { |
| @@ -840,13 +911,11 @@ | ||
| 840 | 911 | )); |
| 841 | 912 | } else { |
| 842 | 913 | # Fallback: Log locally if Sentry fails or is unavailable |
| 843 | 914 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 844 | - error_log(sprintf( | |
| 845 | - 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s', | |
| 915 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 916 | + 'MetaSync Client Report (Fallback): UUID: %s | Severity: %s', | |
| 846 | 917 | $project_uuid, |
| 847 | - $issue_title, | |
| 848 | - $issue_message, | |
| 849 | 918 | $issue_severity |
| 850 | 919 | )); |
| 851 | 920 | } |
| 852 | 921 | |
| @@ -860,9 +929,9 @@ | ||
| 860 | 929 | |
| 861 | 930 | } catch (Exception $e) { |
| 862 | 931 | # Log the error securely (only in debug mode) |
| 863 | 932 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 864 | - error_log(sprintf( | |
| 933 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 865 | 934 | 'MetaSync Report Submission Error: %s in %s on line %d', |
| 866 | 935 | $e->getMessage(), |
| 867 | 936 | $e->getFile(), |
| 868 | 937 | $e->getLine() |
| @@ -875,8 +944,9 @@ | ||
| 875 | 944 | } |
| 876 | 945 | |
| 877 | 946 | public function ajax_recover_password() |
| 878 | 947 | { |
| 948 | + $rate_owner = ''; | |
| 879 | 949 | try { |
| 880 | 950 | if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) { |
| 881 | 951 | wp_send_json_error(array('message' => 'Security verification failed.')); |
| 882 | 952 | return; |
| @@ -881,26 +951,87 @@ | ||
| 881 | 951 | wp_send_json_error(array('message' => 'Security verification failed.')); |
| 882 | 952 | return; |
| 883 | 953 | } |
| 884 | 954 | |
| 955 | + if (!current_user_can('manage_options')) { | |
| 956 | + wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403); | |
| 957 | + return; | |
| 958 | + } | |
| 959 | + | |
| 960 | + if (!$this->acquire_recovery_lock($rate_owner)) { | |
| 961 | + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429); | |
| 962 | + return; | |
| 963 | + } | |
| 964 | + register_shutdown_function(function () use (&$rate_owner) { | |
| 965 | + if ($rate_owner !== '') { | |
| 966 | + Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner); | |
| 967 | + } | |
| 968 | + }); | |
| 969 | + $this->refresh_recovery_state_cache(); | |
| 970 | + | |
| 885 | 971 | $whitelabel_settings = Metasync::get_whitelabel_settings(); |
| 886 | - $password = $whitelabel_settings['settings_password'] ?? ''; | |
| 887 | 972 | $recovery_email = $whitelabel_settings['recovery_email'] ?? ''; |
| 888 | 973 | |
| 889 | - if (empty($password)) { | |
| 974 | + $stored_password = $whitelabel_settings['settings_password'] ?? ''; | |
| 975 | + if (!is_string($stored_password) || $stored_password === '') { | |
| 976 | + $this->release_recovery_lock($rate_owner); | |
| 977 | + $rate_owner = ''; | |
| 890 | 978 | wp_send_json_error(array('message' => 'No password is configured for recovery.')); |
| 891 | 979 | return; |
| 892 | 980 | } |
| 893 | 981 | |
| 894 | 982 | if (empty($recovery_email) || !is_email($recovery_email)) { |
| 983 | + $this->release_recovery_lock($rate_owner); | |
| 984 | + $rate_owner = ''; | |
| 895 | 985 | wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.')); |
| 896 | 986 | return; |
| 897 | 987 | } |
| 898 | 988 | |
| 989 | + // Bound recovery requests per user and per site to limit abuse. | |
| 990 | + $user_id = get_current_user_id(); | |
| 991 | + $user_rate_key = 'metasync_pw_recovery_user_' . $user_id; | |
| 992 | + $site_rate_key = 'metasync_pw_recovery_site'; | |
| 993 | + $this->refresh_recovery_state_cache($user_rate_key); | |
| 994 | + $this->refresh_recovery_state_cache($site_rate_key); | |
| 995 | + $user_attempts = (int) get_transient($user_rate_key); | |
| 996 | + $site_attempts = (int) get_transient($site_rate_key); | |
| 997 | + if ($user_attempts >= 3 || $site_attempts >= 10) { | |
| 998 | + $this->release_recovery_lock($rate_owner); | |
| 999 | + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429); | |
| 1000 | + return; | |
| 1001 | + } | |
| 1002 | + | |
| 1003 | + if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) { | |
| 1004 | + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS); | |
| 1005 | + else delete_transient($user_rate_key); | |
| 1006 | + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS); | |
| 1007 | + else delete_transient($site_rate_key); | |
| 1008 | + $this->release_recovery_lock($rate_owner); | |
| 1009 | + wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.')); | |
| 1010 | + return; | |
| 1011 | + } | |
| 1012 | + | |
| 1013 | + // Keep only a hash in the database; the raw token is sent once by email. | |
| 1014 | + $token = bin2hex(random_bytes(32)); | |
| 1015 | + $token_hash = hash('sha256', $token); | |
| 1016 | + $reset_key = 'metasync_pw_reset_' . $token_hash; | |
| 1017 | + if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) { | |
| 1018 | + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS); | |
| 1019 | + else delete_transient($user_rate_key); | |
| 1020 | + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS); | |
| 1021 | + else delete_transient($site_rate_key); | |
| 1022 | + $this->release_recovery_lock($rate_owner); | |
| 1023 | + $rate_owner = ''; | |
| 1024 | + wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.')); | |
| 1025 | + return; | |
| 1026 | + } | |
| 1027 | + $this->release_recovery_lock($rate_owner); | |
| 1028 | + $rate_owner = ''; | |
| 1029 | + | |
| 899 | 1030 | $site_name = get_bloginfo('name'); |
| 900 | 1031 | $site_url = home_url(); |
| 901 | 1032 | $plugin_name = Metasync::get_effective_plugin_name(''); |
| 902 | - $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel'); | |
| 1033 | + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token)); | |
| 903 | 1034 | $to = $recovery_email; |
| 904 | 1035 | |
| 905 | 1036 | $subject = sprintf('[%s] Settings Password Recovery', $site_name); |
| 906 | 1037 | |
| @@ -906,16 +1037,14 @@ | ||
| 906 | 1037 | |
| 907 | 1038 | $message = sprintf( |
| 908 | 1039 | "Hello,\n\n" . |
| 909 | 1040 | "A password recovery request was made for the %s settings on %s.\n\n" . |
| 910 | - "Your Settings Password is:\n%s\n\n" . | |
| 911 | - "You can use this password to access the protected settings at:\n%s\n\n" . | |
| 1041 | + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" . | |
| 912 | 1042 | "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" . |
| 913 | 1043 | "---\n" . |
| 914 | 1044 | "This is an automated message from %s\n%s", |
| 915 | 1045 | $plugin_name, |
| 916 | 1046 | $site_name, |
| 917 | - $password, | |
| 918 | 1047 | $settings_url, |
| 919 | 1048 | $site_name, |
| 920 | 1049 | $site_url |
| 921 | 1050 | ); |
| @@ -928,13 +1057,8 @@ | ||
| 928 | 1057 | 'Content-Type: text/plain; charset=UTF-8', |
| 929 | 1058 | sprintf('From: %s <%s>', $from_name, get_option('admin_email')) |
| 930 | 1059 | ); |
| 931 | 1060 | |
| 932 | - $mail_error = ''; | |
| 933 | - add_action('wp_mail_failed', function($error) use (&$mail_error) { | |
| 934 | - $mail_error = $error->get_error_message(); | |
| 935 | - }); | |
| 936 | - | |
| 937 | 1061 | $sent = wp_mail($to, $subject, $message, $headers); |
| 938 | 1062 | |
| 939 | 1063 | if ($sent) { |
| 940 | 1064 | wp_send_json_success(array( |
| @@ -940,21 +1064,19 @@ | ||
| 940 | 1064 | wp_send_json_success(array( |
| 941 | 1065 | 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email)) |
| 942 | 1066 | )); |
| 943 | 1067 | } else { |
| 944 | - $error_message = 'Failed to send recovery email. '; | |
| 945 | - if (!empty($mail_error)) { | |
| 946 | - $error_message .= 'Error: ' . $mail_error; | |
| 947 | - } else { | |
| 948 | - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.'; | |
| 949 | - } | |
| 950 | - | |
| 951 | - wp_send_json_error(array('message' => $error_message)); | |
| 1068 | + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.')); | |
| 952 | 1069 | } |
| 953 | 1070 | |
| 954 | 1071 | } catch (Exception $e) { |
| 1072 | + // Always release the bounded issuance lock if an unexpected | |
| 1073 | + // exception occurs after it was acquired. | |
| 1074 | + if ($rate_owner !== '') { | |
| 1075 | + $this->release_recovery_lock($rate_owner); | |
| 1076 | + } | |
| 955 | 1077 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 956 | - error_log(sprintf( | |
| 1078 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 957 | 1079 | 'MetaSync Password Recovery Error: %s in %s on line %d', |
| 958 | 1080 | $e->getMessage(), |
| 959 | 1081 | $e->getFile(), |
| 960 | 1082 | $e->getLine() |
| @@ -964,8 +1086,113 @@ | ||
| 964 | 1086 | wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.')); |
| 965 | 1087 | } |
| 966 | 1088 | } |
| 967 | 1089 | |
| 1090 | + /** Render and process the administrator-only password reset screen. */ | |
| 1091 | + public function render_password_reset_page() | |
| 1092 | + { | |
| 1093 | + if (!current_user_can('manage_options')) { | |
| 1094 | + wp_die('You do not have permission to reset this password.', '', array('response' => 403)); | |
| 1095 | + } | |
| 1096 | + | |
| 1097 | + $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : ''; | |
| 1098 | + $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : ''; | |
| 1099 | + $generation = (int) get_option('metasync_pw_reset_generation', 0); | |
| 1100 | + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : ''; | |
| 1101 | + $error = ''; | |
| 1102 | + $success = false; | |
| 1103 | + | |
| 1104 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 1105 | + if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) { | |
| 1106 | + $error = 'Security verification failed.'; | |
| 1107 | + } elseif ($token === '' || false === get_transient($transient_key)) { | |
| 1108 | + $error = 'This recovery link is invalid or has expired.'; | |
| 1109 | + } else { | |
| 1110 | + $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : ''; | |
| 1111 | + $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : ''; | |
| 1112 | + if (strlen($password) < 8) { | |
| 1113 | + $error = 'Password must be at least 8 characters long.'; | |
| 1114 | + } elseif (!hash_equals($password, $confirm)) { | |
| 1115 | + $error = 'Passwords do not match.'; | |
| 1116 | + } elseif (strpos($password, 'enc_v1:') === 0) { | |
| 1117 | + $error = 'Unable to save this password. Please choose a different password.'; | |
| 1118 | + } else { | |
| 1119 | + // add_option is an atomic insert in WordPress; it closes the | |
| 1120 | + // race where two requests attempt to redeem the same token. | |
| 1121 | + $lock_key = ''; | |
| 1122 | + if (!$this->acquire_recovery_lock($lock_key)) { | |
| 1123 | + $error = 'Another recovery request is being processed. Please wait a moment and try again.'; | |
| 1124 | + } else { | |
| 1125 | + register_shutdown_function(function () use (&$lock_key) { | |
| 1126 | + if ($lock_key !== '') { | |
| 1127 | + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key); | |
| 1128 | + } | |
| 1129 | + }); | |
| 1130 | + $this->refresh_recovery_state_cache($transient_key); | |
| 1131 | + // Refresh the settings version while holding the shared | |
| 1132 | + // lock. Token keys are independent so issuing or using | |
| 1133 | + // one link does not invalidate another unexpired link. | |
| 1134 | + $generation = (int) get_option('metasync_pw_reset_generation', 0); | |
| 1135 | + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : ''; | |
| 1136 | + // Re-check after claiming the lock. Consume only after | |
| 1137 | + // every required write succeeds, allowing a retry after | |
| 1138 | + // a temporary database or revocation failure. | |
| 1139 | + if (false === get_transient($transient_key)) { | |
| 1140 | + $error = 'This recovery link is invalid or has already been used.'; | |
| 1141 | + $this->release_recovery_lock($lock_key); | |
| 1142 | + $lock_key = ''; | |
| 1143 | + echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>'; | |
| 1144 | + return; | |
| 1145 | + } | |
| 1146 | + { | |
| 1147 | + $encrypted = Metasync::encrypt_secret($password); | |
| 1148 | + if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) { | |
| 1149 | + $error = 'Unable to securely save this password. Please try again later.'; | |
| 1150 | + } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) { | |
| 1151 | + $error = 'Unable to prepare the password reset. Please try again later.'; | |
| 1152 | + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */ | |
| 1153 | + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) { | |
| 1154 | + $error = 'Unable to revoke existing access. Please try again later.'; | |
| 1155 | + } elseif (!$this->persist_recovery_password($encrypted)) { | |
| 1156 | + $error = 'Unable to save the new password. Please try again later.'; | |
| 1157 | + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */ | |
| 1158 | + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) { | |
| 1159 | + $error = 'Unable to finalize access revocation. Please try again later.'; | |
| 1160 | + } elseif (!delete_transient($transient_key)) { | |
| 1161 | + $error = 'Unable to consume this recovery link. Please try again later.'; | |
| 1162 | + } else { | |
| 1163 | + $success = true; | |
| 1164 | + } | |
| 1165 | + } | |
| 1166 | + $this->release_recovery_lock($lock_key); | |
| 1167 | + $lock_key = ''; | |
| 1168 | + } | |
| 1169 | + } | |
| 1170 | + } | |
| 1171 | + } | |
| 1172 | + | |
| 1173 | + echo '<div class="wrap"><h1>Reset Settings Password</h1>'; | |
| 1174 | + if ($success) { | |
| 1175 | + echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>'; | |
| 1176 | + } else { | |
| 1177 | + if ($error !== '') { | |
| 1178 | + echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>'; | |
| 1179 | + } | |
| 1180 | + if ($token === '' || false === get_transient($transient_key)) { | |
| 1181 | + if ($error === '') { | |
| 1182 | + echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>'; | |
| 1183 | + } | |
| 1184 | + } else { | |
| 1185 | + echo '<form method="post" style="max-width:420px">'; | |
| 1186 | + wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce'); | |
| 1187 | + echo '<p><label for="new_password">New password</label><br><input class="regular-text" type="password" id="new_password" name="new_password" minlength="8" required autocomplete="new-password"></p>'; | |
| 1188 | + echo '<p><label for="confirm_password">Confirm new password</label><br><input class="regular-text" type="password" id="confirm_password" name="confirm_password" minlength="8" required autocomplete="new-password"></p>'; | |
| 1189 | + echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>'; | |
| 1190 | + } | |
| 1191 | + } | |
| 1192 | + echo '</div>'; | |
| 1193 | + } | |
| 1194 | + | |
| 968 | 1195 | public function ajax_save_theme() |
| 969 | 1196 | { |
| 970 | 1197 | try { |
| 971 | 1198 | # Verify nonce for security |
| @@ -970,15 +1197,13 @@ | ||
| 970 | 1197 | try { |
| 971 | 1198 | # Verify nonce for security |
| 972 | 1199 | if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) { |
| 973 | 1200 | wp_send_json_error(array('message' => 'Security verification failed.')); |
| 974 | - return; | |
| 975 | 1201 | } |
| 976 | 1202 | |
| 977 | 1203 | # Check user capabilities |
| 978 | 1204 | if (!Metasync::current_user_has_plugin_access()) { |
| 979 | 1205 | wp_send_json_error(array('message' => 'Insufficient permissions.')); |
| 980 | - return; | |
| 981 | 1206 | } |
| 982 | 1207 | |
| 983 | 1208 | # Get and validate theme value |
| 984 | 1209 | $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : ''; |
| @@ -985,9 +1210,8 @@ | ||
| 985 | 1210 | |
| 986 | 1211 | # Validate theme is either 'light' or 'dark' |
| 987 | 1212 | if (!in_array($theme, array('light', 'dark'), true)) { |
| 988 | 1213 | wp_send_json_error(array('message' => 'Invalid theme value.')); |
| 989 | - return; | |
| 990 | 1214 | } |
| 991 | 1215 | |
| 992 | 1216 | # Save theme preference to WordPress options |
| 993 | 1217 | update_option('metasync_theme', $theme, true); |
| @@ -1000,9 +1224,9 @@ | ||
| 1000 | 1224 | |
| 1001 | 1225 | } catch (Exception $e) { |
| 1002 | 1226 | # Log error if debug is enabled |
| 1003 | 1227 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 1004 | - error_log('MetaSync Theme Save Error: ' . $e->getMessage()); | |
| 1228 | + error_log('MetaSync Theme Save Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 1005 | 1229 | } |
| 1006 | 1230 | |
| 1007 | 1231 | wp_send_json_error(array('message' => 'Failed to save theme preference.')); |
| 1008 | 1232 | } |
| @@ -1013,9 +1237,8 @@ | ||
| 1013 | 1237 | check_ajax_referer('metasync_nonce', 'nonce'); |
| 1014 | 1238 | # Check user capabilities |
| 1015 | 1239 | if (!Metasync::current_user_has_plugin_access()) { |
| 1016 | 1240 | wp_send_json_error(['message' => 'Insufficient permissions']); |
| 1017 | - return; | |
| 1018 | 1241 | } |
| 1019 | 1242 | |
| 1020 | 1243 | # Get parameters |
| 1021 | 1244 | $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect'; |
| @@ -1057,32 +1280,20 @@ | ||
| 1057 | 1280 | wp_die('Security verification failed.'); |
| 1058 | 1281 | } |
| 1059 | 1282 | |
| 1060 | 1283 | try { |
| 1061 | - # Get all whitelabel settings | |
| 1062 | - $whitelabel_settings = Metasync::get_whitelabel_settings(); | |
| 1063 | - | |
| 1064 | - # Get general settings that relate to whitelabel | |
| 1065 | - $general_settings = Metasync::get_option('general'); | |
| 1066 | - $whitelabel_related_general = array(); | |
| 1067 | - | |
| 1068 | - # Include ALL whitelabel-related general settings | |
| 1069 | - $whitelabel_keys = array( | |
| 1070 | - 'white_label_plugin_name', | |
| 1071 | - 'white_label_plugin_description', | |
| 1072 | - 'white_label_plugin_author', | |
| 1073 | - 'white_label_plugin_author_uri', | |
| 1074 | - 'white_label_plugin_uri', | |
| 1075 | - 'white_label_plugin_menu_slug', | |
| 1076 | - 'white_label_plugin_menu_icon', | |
| 1077 | - 'whitelabel_otto_name' | |
| 1078 | - ); | |
| 1079 | - | |
| 1080 | - foreach ($whitelabel_keys as $key) { | |
| 1081 | - if (isset($general_settings[$key])) { | |
| 1082 | - $whitelabel_related_general[$key] = $general_settings[$key]; | |
| 1083 | - } | |
| 1284 | + # Source the white-label export payload from the shared helper so | |
| 1285 | + # reset and export can never drift apart: whatever the reset | |
| 1286 | + # preserves is exactly what the export serialises. No API | |
| 1287 | + # credentials (apikey / searchatlas_api_key / otto_pixel_uuid / ...) | |
| 1288 | + # are ever serialised - the helper only captures the whitelabel | |
| 1289 | + # blob and the general branding keys. | |
| 1290 | + if (!class_exists('Metasync_Whitelabel_Preservation')) { | |
| 1291 | + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-whitelabel-preservation.php'; | |
| 1084 | 1292 | } |
| 1293 | + $export_payload = Metasync_Whitelabel_Preservation::build_whitelabel_export_data(); | |
| 1294 | + $whitelabel_settings = $export_payload['whitelabel_settings']; | |
| 1295 | + $whitelabel_related_general = $export_payload['general_settings']; | |
| 1085 | 1296 | |
| 1086 | 1297 | # Bundle the menu icon file so it survives import on a different site. |
| 1087 | 1298 | # If the icon is a local URL (media library), replace it with a special |
| 1088 | 1299 | # marker and include the actual file in the ZIP under the plugin folder. |
| @@ -1136,9 +1347,9 @@ | ||
| 1136 | 1347 | wp_mkdir_p($temp_dir); |
| 1137 | 1348 | } |
| 1138 | 1349 | |
| 1139 | 1350 | # Generate unique filename |
| 1140 | - $timestamp = date('Y-m-d_H-i-s'); | |
| 1351 | + $timestamp = gmdate('Y-m-d_H-i-s'); | |
| 1141 | 1352 | $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip'; |
| 1142 | 1353 | $json_filename = 'whitelabel-settings.json'; |
| 1143 | 1354 | $zip_path = $temp_dir . '/' . $zip_filename; |
| 1144 | 1355 | |
| @@ -1199,14 +1410,14 @@ | ||
| 1199 | 1410 | header('Pragma: no-cache'); |
| 1200 | 1411 | header('Expires: 0'); |
| 1201 | 1412 | |
| 1202 | 1413 | # Output file and clean up |
| 1203 | - readfile($zip_path); | |
| 1204 | - unlink($zip_path); | |
| 1414 | + readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams a large file to output without loading it into memory | |
| 1415 | + wp_delete_file($zip_path); | |
| 1205 | 1416 | |
| 1206 | 1417 | # Clean up temp directory if empty |
| 1207 | 1418 | if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) { |
| 1208 | - rmdir($temp_dir); | |
| 1419 | + rmdir($temp_dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts | |
| 1209 | 1420 | } |
| 1210 | 1421 | |
| 1211 | 1422 | # Exit to prevent WordPress from adding anything to the response |
| 1212 | 1423 | exit; |
| @@ -1213,12 +1424,12 @@ | ||
| 1213 | 1424 | |
| 1214 | 1425 | } catch (Exception $e) { |
| 1215 | 1426 | # Log error if debug is enabled |
| 1216 | 1427 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 1217 | - error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); | |
| 1428 | + error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 1218 | 1429 | } |
| 1219 | 1430 | |
| 1220 | - wp_die('Failed to export whitelabel settings: ' . $e->getMessage()); | |
| 1431 | + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync')); | |
| 1221 | 1432 | } |
| 1222 | 1433 | } |
| 1223 | 1434 | |
| 1224 | 1435 | private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array()) |
| @@ -1263,10 +1474,10 @@ | ||
| 1263 | 1474 | public function render_html_pages_dashboard_widget() |
| 1264 | 1475 | { |
| 1265 | 1476 | global $wpdb; |
| 1266 | 1477 | |
| 1267 | - $query = " | |
| 1268 | - SELECT p.ID, p.post_title, p.post_modified, p.post_type | |
| 1478 | + $html_pages = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin dashboard widget join over posts+postmeta — aggregate view, no WordPress API for the join | |
| 1479 | + "SELECT p.ID, p.post_title, p.post_modified, p.post_type | |
| 1269 | 1480 | FROM {$wpdb->posts} p |
| 1270 | 1481 | INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id |
| 1271 | 1482 | WHERE p.post_status = 'publish' |
| 1272 | 1483 | AND p.post_type IN ('post', 'page') |
| @@ -1273,11 +1484,10 @@ | ||
| 1273 | 1484 | AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css') |
| 1274 | 1485 | GROUP BY p.ID |
| 1275 | 1486 | ORDER BY p.post_modified DESC |
| 1276 | 1487 | LIMIT 10 |
| 1277 | - "; | |
| 1278 | - | |
| 1279 | - $html_pages = $wpdb->get_results($query); | |
| 1488 | + " | |
| 1489 | + ); | |
| 1280 | 1490 | $total_count = count($html_pages); |
| 1281 | 1491 | |
| 1282 | 1492 | $label = $this->get_html_source_label(); |
| 1283 | 1493 | |
| @@ -1284,13 +1494,14 @@ | ||
| 1284 | 1494 | if (empty($html_pages)) { |
| 1285 | 1495 | echo '<div class="metasync-dashboard-widget-empty">'; |
| 1286 | 1496 | echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>'; |
| 1287 | 1497 | echo '<p style="text-align: center; color: #666;">'; |
| 1288 | - echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>'); | |
| 1498 | + /* translators: %s: HTML source label. */ | |
| 1499 | + echo sprintf(esc_html__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>'); | |
| 1289 | 1500 | echo '</p>'; |
| 1290 | 1501 | echo '<p style="text-align: center;">'; |
| 1291 | - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">'; | |
| 1292 | - echo __('Get Started', 'metasync'); | |
| 1502 | + echo '<a href="' . esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug)) . '" class="button button-primary">'; | |
| 1503 | + echo esc_html__('Get Started', 'metasync'); | |
| 1293 | 1504 | echo '</a>'; |
| 1294 | 1505 | echo '</p>'; |
| 1295 | 1506 | echo '</div>'; |
| 1296 | 1507 | return; |
| @@ -1299,15 +1510,15 @@ | ||
| 1299 | 1510 | echo '<div class="metasync-dashboard-widget">'; |
| 1300 | 1511 | |
| 1301 | 1512 | echo '<div class="metasync-widget-stats">'; |
| 1302 | 1513 | echo '<div class="metasync-stat-box">'; |
| 1303 | - echo '<span class="metasync-stat-number">' . $total_count . '</span>'; | |
| 1304 | - echo '<span class="metasync-stat-label">' . __('AI-Generated Pages', 'metasync') . '</span>'; | |
| 1514 | + echo '<span class="metasync-stat-number">' . esc_html((string) $total_count) . '</span>'; | |
| 1515 | + echo '<span class="metasync-stat-label">' . esc_html__('AI-Generated Pages', 'metasync') . '</span>'; | |
| 1305 | 1516 | echo '</div>'; |
| 1306 | 1517 | echo '</div>'; |
| 1307 | 1518 | |
| 1308 | 1519 | echo '<div class="metasync-widget-list">'; |
| 1309 | - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>'; | |
| 1520 | + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>'; | |
| 1310 | 1521 | echo '<ul>'; |
| 1311 | 1522 | |
| 1312 | 1523 | foreach ($html_pages as $page) { |
| 1313 | 1524 | $edit_link = get_edit_post_link($page->ID); |
| @@ -1320,11 +1531,12 @@ | ||
| 1320 | 1531 | echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">'; |
| 1321 | 1532 | echo esc_html($page->post_title ?: __('(no title)', 'metasync')); |
| 1322 | 1533 | echo '</a>'; |
| 1323 | 1534 | echo '<span class="metasync-page-meta">'; |
| 1324 | - echo sprintf(__('Updated %s ago', 'metasync'), $time_ago); | |
| 1535 | + /* translators: %s: human-readable time difference. */ | |
| 1536 | + echo esc_html(sprintf(__('Updated %s ago', 'metasync'), $time_ago)); | |
| 1325 | 1537 | echo ' • '; |
| 1326 | - echo '<a href="' . esc_url($view_link) . '" target="_blank">' . __('View', 'metasync') . '</a>'; | |
| 1538 | + echo '<a href="' . esc_url($view_link) . '" target="_blank">' . esc_html__('View', 'metasync') . '</a>'; | |
| 1327 | 1539 | echo '</span>'; |
| 1328 | 1540 | echo '</div>'; |
| 1329 | 1541 | echo '</li>'; |
| 1330 | 1542 | } |
| @@ -1332,10 +1544,10 @@ | ||
| 1332 | 1544 | echo '</ul>'; |
| 1333 | 1545 | echo '</div>'; |
| 1334 | 1546 | |
| 1335 | 1547 | echo '<div class="metasync-widget-footer">'; |
| 1336 | - echo '<a href="' . admin_url('edit.php?post_type=page') . '">'; | |
| 1337 | - echo __('View All Pages', 'metasync') . ' →'; | |
| 1548 | + echo '<a href="' . esc_url(admin_url("edit.php?post_type=page")) . '">'; | |
| 1549 | + echo esc_html__('View All Pages', 'metasync') . ' →'; | |
| 1338 | 1550 | echo '</a>'; |
| 1339 | 1551 | echo '</div>'; |
| 1340 | 1552 | |
| 1341 | 1553 | echo '</div>'; |