| @@ -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,10 @@ | ||
| 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'])) : ''; | |
| 47 | 144 | $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0; |
| 48 | 145 | |
| 49 | 146 | if (empty($type) || empty($plugin)) { |
| 50 | 147 | wp_send_json_error(['message' => 'Missing required parameters.']); |
| @@ -82,9 +179,9 @@ | ||
| 82 | 179 | public function ajax_import_seo_metadata() |
| 83 | 180 | { |
| 84 | 181 | $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time'); |
| 85 | 182 | if (function_exists('set_time_limit')) { |
| 86 | - @set_time_limit($execution_time); | |
| 183 | + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API | |
| 87 | 184 | } |
| 88 | 185 | |
| 89 | 186 | Metasync_Settings_Fields::instance()->apply_memory_limit(); |
| 90 | 187 | |
| @@ -93,9 +190,9 @@ | ||
| 93 | 190 | if (!Metasync::current_user_has_plugin_access()) { |
| 94 | 191 | wp_send_json_error(['message' => 'Insufficient permissions.']); |
| 95 | 192 | } |
| 96 | 193 | |
| 97 | - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : ''; | |
| 194 | + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : ''; | |
| 98 | 195 | $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true; |
| 99 | 196 | $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true; |
| 100 | 197 | $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true; |
| 101 | 198 | $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true; |
| @@ -466,10 +563,10 @@ | ||
| 466 | 563 | if (!Metasync::current_user_has_plugin_access()) { |
| 467 | 564 | wp_die('Insufficient permissions'); |
| 468 | 565 | } |
| 469 | 566 | |
| 470 | - $uri = sanitize_text_field($_POST['uri']); | |
| 471 | - $redirect_url = sanitize_url($_POST['redirect_url']); | |
| 567 | + $uri = sanitize_text_field(wp_unslash($_POST['uri'])); | |
| 568 | + $redirect_url = sanitize_url(wp_unslash($_POST['redirect_url'])); | |
| 472 | 569 | |
| 473 | 570 | if (empty($uri) || empty($redirect_url)) { |
| 474 | 571 | wp_send_json_error('Missing required parameters'); |
| 475 | 572 | } |
| @@ -487,91 +584,33 @@ | ||
| 487 | 584 | wp_send_json_error('Failed to create redirect'); |
| 488 | 585 | } |
| 489 | 586 | } |
| 490 | 587 | |
| 588 | + /** | |
| 589 | + * Manual "Host Blocking Test" — GET leg. | |
| 590 | + * | |
| 591 | + * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the | |
| 592 | + * automatic activation/weekly check share one implementation. | |
| 593 | + */ | |
| 491 | 594 | public function ajax_test_host_blocking_get() |
| 492 | 595 | { |
| 493 | - check_ajax_referer('metasync_nonce', 'nonce'); | |
| 494 | - if (!Metasync::current_user_has_plugin_access()) { | |
| 495 | - wp_send_json_error('Insufficient permissions'); | |
| 496 | - return; | |
| 497 | - } | |
| 596 | + $this->send_host_blocking_result('GET'); | |
| 597 | + } | |
| 498 | 598 | |
| 499 | - $endpoint = 'https://wp-check.searchatlas.com/ping'; | |
| 500 | - $start_time = microtime(true); | |
| 501 | - | |
| 502 | - $response = wp_remote_get($endpoint, array( | |
| 503 | - 'timeout' => 30, | |
| 504 | - 'user-agent' => 'MetaSync Plugin Host Test', | |
| 505 | - 'headers' => array( | |
| 506 | - 'Accept' => 'application/json', | |
| 507 | - 'Content-Type' => 'application/json', | |
| 508 | - 'Origin' => home_url(), | |
| 509 | - 'Referer' => admin_url(), | |
| 510 | - 'X-WordPress-Site' => home_url() | |
| 511 | - ) | |
| 512 | - )); | |
| 513 | - | |
| 514 | - $end_time = microtime(true); | |
| 515 | - $response_time = round(($end_time - $start_time) * 1000, 2); | |
| 516 | - | |
| 517 | - if (is_wp_error($response)) { | |
| 518 | - wp_send_json_success(array( | |
| 519 | - 'method' => 'GET', | |
| 520 | - 'status' => 'error', | |
| 521 | - 'response_time' => $response_time . 'ms', | |
| 522 | - 'error' => $response->get_error_message(), | |
| 523 | - 'blocked' => true, | |
| 524 | - 'details' => 'Request failed - possible blocking detected' | |
| 525 | - )); | |
| 526 | - } else { | |
| 527 | - $status_code = wp_remote_retrieve_response_code($response); | |
| 528 | - $body = wp_remote_retrieve_body($response); | |
| 529 | - $headers = wp_remote_retrieve_headers($response); | |
| 530 | - | |
| 531 | - $is_blocked = false; | |
| 532 | - $status_text = 'success'; | |
| 533 | - $details = 'GET request completed successfully'; | |
| 534 | - $parsed_response = null; | |
| 535 | - | |
| 536 | - if ($status_code === 200) { | |
| 537 | - $parsed_response = json_decode($body, true); | |
| 538 | - | |
| 539 | - if ($parsed_response && isset($parsed_response['results']['get'])) { | |
| 540 | - $get_result = $parsed_response['results']['get']; | |
| 541 | - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null; | |
| 542 | - | |
| 543 | - if ($get_status_code !== 200) { | |
| 544 | - $is_blocked = true; | |
| 545 | - $status_text = 'error'; | |
| 546 | - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected"; | |
| 547 | - } | |
| 548 | - } else { | |
| 549 | - $is_blocked = true; | |
| 550 | - $status_text = 'error'; | |
| 551 | - $details = 'Unable to parse response structure - possible blocking or endpoint issue'; | |
| 552 | - } | |
| 553 | - } else { | |
| 554 | - $is_blocked = true; | |
| 555 | - $status_text = 'error'; | |
| 556 | - $details = "External endpoint returned status code {$status_code} - possible blocking detected"; | |
| 557 | - } | |
| 558 | - | |
| 559 | - wp_send_json_success(array( | |
| 560 | - 'method' => 'GET', | |
| 561 | - 'status' => $status_text, | |
| 562 | - 'response_time' => $response_time . 'ms', | |
| 563 | - 'status_code' => $status_code, | |
| 564 | - 'body' => $body, | |
| 565 | - 'headers' => $headers->getAll(), | |
| 566 | - 'blocked' => $is_blocked, | |
| 567 | - 'details' => $details, | |
| 568 | - 'parsed_response' => $parsed_response | |
| 569 | - )); | |
| 570 | - } | |
| 599 | + /** | |
| 600 | + * Manual "Host Blocking Test" — POST leg. | |
| 601 | + */ | |
| 602 | + public function ajax_test_host_blocking_post() | |
| 603 | + { | |
| 604 | + $this->send_host_blocking_result('POST'); | |
| 571 | 605 | } |
| 572 | 606 | |
| 573 | - public function ajax_test_host_blocking_post() | |
| 607 | + /** | |
| 608 | + * Shared auth + response wrapper for both manual host blocking legs. | |
| 609 | + * | |
| 610 | + * @param string $method 'GET' or 'POST'. | |
| 611 | + */ | |
| 612 | + private function send_host_blocking_result($method) | |
| 574 | 613 | { |
| 575 | 614 | check_ajax_referer('metasync_nonce', 'nonce'); |
| 576 | 615 | if (!Metasync::current_user_has_plugin_access()) { |
| 577 | 616 | wp_send_json_error('Insufficient permissions'); |
| @@ -577,90 +616,9 @@ | ||
| 577 | 616 | wp_send_json_error('Insufficient permissions'); |
| 578 | 617 | return; |
| 579 | 618 | } |
| 580 | 619 | |
| 581 | - $endpoint = 'https://wp-check.searchatlas.com/ping'; | |
| 582 | - $start_time = microtime(true); | |
| 583 | - | |
| 584 | - $test_data = array( | |
| 585 | - 'test' => 'host_blocking_test', | |
| 586 | - 'timestamp' => current_time('mysql'), | |
| 587 | - 'source' => 'metasync_plugin', | |
| 588 | - 'method' => 'POST' | |
| 589 | - ); | |
| 590 | - | |
| 591 | - $response = wp_remote_post($endpoint, array( | |
| 592 | - 'timeout' => 30, | |
| 593 | - 'user-agent' => 'MetaSync Plugin Host Test', | |
| 594 | - 'headers' => array( | |
| 595 | - 'Accept' => 'application/json', | |
| 596 | - 'Content-Type' => 'application/json', | |
| 597 | - 'Origin' => home_url(), | |
| 598 | - 'Referer' => admin_url(), | |
| 599 | - 'X-WordPress-Site' => home_url() | |
| 600 | - ), | |
| 601 | - 'body' => json_encode($test_data) | |
| 602 | - )); | |
| 603 | - | |
| 604 | - $end_time = microtime(true); | |
| 605 | - $response_time = round(($end_time - $start_time) * 1000, 2); | |
| 606 | - | |
| 607 | - if (is_wp_error($response)) { | |
| 608 | - wp_send_json_success(array( | |
| 609 | - 'method' => 'POST', | |
| 610 | - 'status' => 'error', | |
| 611 | - 'response_time' => $response_time . 'ms', | |
| 612 | - 'error' => $response->get_error_message(), | |
| 613 | - 'blocked' => true, | |
| 614 | - 'details' => 'Request failed - possible blocking detected', | |
| 615 | - 'sent_data' => $test_data | |
| 616 | - )); | |
| 617 | - } else { | |
| 618 | - $status_code = wp_remote_retrieve_response_code($response); | |
| 619 | - $body = wp_remote_retrieve_body($response); | |
| 620 | - $headers = wp_remote_retrieve_headers($response); | |
| 621 | - | |
| 622 | - $is_blocked = false; | |
| 623 | - $status_text = 'success'; | |
| 624 | - $details = 'POST request completed successfully'; | |
| 625 | - $parsed_response = null; | |
| 626 | - | |
| 627 | - if ($status_code === 200) { | |
| 628 | - $parsed_response = json_decode($body, true); | |
| 629 | - | |
| 630 | - if ($parsed_response && isset($parsed_response['results']['post'])) { | |
| 631 | - $post_result = $parsed_response['results']['post']; | |
| 632 | - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null; | |
| 633 | - | |
| 634 | - if ($post_status_code !== 200) { | |
| 635 | - $is_blocked = true; | |
| 636 | - $status_text = 'error'; | |
| 637 | - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected"; | |
| 638 | - } | |
| 639 | - } else { | |
| 640 | - $is_blocked = true; | |
| 641 | - $status_text = 'error'; | |
| 642 | - $details = 'Unable to parse response structure - possible blocking or endpoint issue'; | |
| 643 | - } | |
| 644 | - } else { | |
| 645 | - $is_blocked = true; | |
| 646 | - $status_text = 'error'; | |
| 647 | - $details = "External endpoint returned status code {$status_code} - possible blocking detected"; | |
| 648 | - } | |
| 649 | - | |
| 650 | - wp_send_json_success(array( | |
| 651 | - 'method' => 'POST', | |
| 652 | - 'status' => $status_text, | |
| 653 | - 'response_time' => $response_time . 'ms', | |
| 654 | - 'status_code' => $status_code, | |
| 655 | - 'body' => $body, | |
| 656 | - 'headers' => $headers->getAll(), | |
| 657 | - 'blocked' => $is_blocked, | |
| 658 | - 'details' => $details, | |
| 659 | - 'sent_data' => $test_data, | |
| 660 | - 'parsed_response' => $parsed_response | |
| 661 | - )); | |
| 662 | - } | |
| 620 | + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method)); | |
| 663 | 621 | } |
| 664 | 622 | |
| 665 | 623 | public function execute_transient_cleanup() |
| 666 | 624 | { |
| @@ -677,9 +635,9 @@ | ||
| 677 | 635 | try { |
| 678 | 636 | delete_expired_transients(true); |
| 679 | 637 | $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress'; |
| 680 | 638 | |
| 681 | - $plugin_transients = $wpdb->get_results( | |
| 639 | + $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 | |
| 682 | 640 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'", |
| 683 | 641 | ARRAY_A |
| 684 | 642 | ); |
| 685 | 643 | |
| @@ -688,9 +646,9 @@ | ||
| 688 | 646 | delete_transient($transient_name); |
| 689 | 647 | $cleanup_stats['plugin_transients']++; |
| 690 | 648 | } |
| 691 | 649 | |
| 692 | - $rate_limit_transients = $wpdb->get_results( | |
| 650 | + $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 | |
| 693 | 651 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'", |
| 694 | 652 | ARRAY_A |
| 695 | 653 | ); |
| 696 | 654 | |
| @@ -699,9 +657,9 @@ | ||
| 699 | 657 | delete_transient($transient_name); |
| 700 | 658 | $cleanup_stats['rate_limit_transients']++; |
| 701 | 659 | } |
| 702 | 660 | |
| 703 | - $telemetry_transients = $wpdb->get_results( | |
| 661 | + $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 | |
| 704 | 662 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'", |
| 705 | 663 | ARRAY_A |
| 706 | 664 | ); |
| 707 | 665 | |
| @@ -710,9 +668,9 @@ | ||
| 710 | 668 | delete_transient($transient_name); |
| 711 | 669 | $cleanup_stats['telemetry_transients']++; |
| 712 | 670 | } |
| 713 | 671 | |
| 714 | - $sa_connect_success_transients = $wpdb->get_results( | |
| 672 | + $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 | |
| 715 | 673 | "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'", |
| 716 | 674 | ARRAY_A |
| 717 | 675 | ); |
| 718 | 676 | |
| @@ -723,14 +681,12 @@ | ||
| 723 | 681 | } |
| 724 | 682 | |
| 725 | 683 | $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2); |
| 726 | 684 | $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ? |
| 727 | - date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A'; | |
| 685 | + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A'; | |
| 728 | 686 | |
| 729 | - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats)); | |
| 730 | - | |
| 731 | 687 | } catch (Exception $e) { |
| 732 | - error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); | |
| 688 | + error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 733 | 689 | } |
| 734 | 690 | } |
| 735 | 691 | |
| 736 | 692 | public function ajax_submit_issue_report() |
| @@ -860,9 +816,9 @@ | ||
| 860 | 816 | # Check if Sentry feedback function exists |
| 861 | 817 | if (!function_exists('metasync_sentry_capture_feedback')) { |
| 862 | 818 | # Log warning if function doesn't exist |
| 863 | 819 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 864 | - error_log('MetaSync: Sentry feedback function not available for report submission.'); | |
| 820 | + error_log('MetaSync: Sentry feedback function not available for report submission.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 865 | 821 | } |
| 866 | 822 | } else { |
| 867 | 823 | $feedback_data = array( |
| 868 | 824 | 'message' => $issue_message, |
| @@ -889,13 +845,11 @@ | ||
| 889 | 845 | )); |
| 890 | 846 | } else { |
| 891 | 847 | # Fallback: Log locally if Sentry fails or is unavailable |
| 892 | 848 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 893 | - error_log(sprintf( | |
| 894 | - 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s', | |
| 849 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 850 | + 'MetaSync Client Report (Fallback): UUID: %s | Severity: %s', | |
| 895 | 851 | $project_uuid, |
| 896 | - $issue_title, | |
| 897 | - $issue_message, | |
| 898 | 852 | $issue_severity |
| 899 | 853 | )); |
| 900 | 854 | } |
| 901 | 855 | |
| @@ -909,9 +863,9 @@ | ||
| 909 | 863 | |
| 910 | 864 | } catch (Exception $e) { |
| 911 | 865 | # Log the error securely (only in debug mode) |
| 912 | 866 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 913 | - error_log(sprintf( | |
| 867 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 914 | 868 | 'MetaSync Report Submission Error: %s in %s on line %d', |
| 915 | 869 | $e->getMessage(), |
| 916 | 870 | $e->getFile(), |
| 917 | 871 | $e->getLine() |
| @@ -924,8 +878,9 @@ | ||
| 924 | 878 | } |
| 925 | 879 | |
| 926 | 880 | public function ajax_recover_password() |
| 927 | 881 | { |
| 882 | + $rate_owner = ''; | |
| 928 | 883 | try { |
| 929 | 884 | if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) { |
| 930 | 885 | wp_send_json_error(array('message' => 'Security verification failed.')); |
| 931 | 886 | return; |
| @@ -930,26 +885,87 @@ | ||
| 930 | 885 | wp_send_json_error(array('message' => 'Security verification failed.')); |
| 931 | 886 | return; |
| 932 | 887 | } |
| 933 | 888 | |
| 889 | + if (!current_user_can('manage_options')) { | |
| 890 | + wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403); | |
| 891 | + return; | |
| 892 | + } | |
| 893 | + | |
| 894 | + if (!$this->acquire_recovery_lock($rate_owner)) { | |
| 895 | + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429); | |
| 896 | + return; | |
| 897 | + } | |
| 898 | + register_shutdown_function(function () use (&$rate_owner) { | |
| 899 | + if ($rate_owner !== '') { | |
| 900 | + Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner); | |
| 901 | + } | |
| 902 | + }); | |
| 903 | + $this->refresh_recovery_state_cache(); | |
| 904 | + | |
| 934 | 905 | $whitelabel_settings = Metasync::get_whitelabel_settings(); |
| 935 | - $password = Metasync::get_whitelabel_password(); | |
| 936 | 906 | $recovery_email = $whitelabel_settings['recovery_email'] ?? ''; |
| 937 | 907 | |
| 938 | - if (empty($password)) { | |
| 908 | + $stored_password = $whitelabel_settings['settings_password'] ?? ''; | |
| 909 | + if (!is_string($stored_password) || $stored_password === '') { | |
| 910 | + $this->release_recovery_lock($rate_owner); | |
| 911 | + $rate_owner = ''; | |
| 939 | 912 | wp_send_json_error(array('message' => 'No password is configured for recovery.')); |
| 940 | 913 | return; |
| 941 | 914 | } |
| 942 | 915 | |
| 943 | 916 | if (empty($recovery_email) || !is_email($recovery_email)) { |
| 917 | + $this->release_recovery_lock($rate_owner); | |
| 918 | + $rate_owner = ''; | |
| 944 | 919 | wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.')); |
| 945 | 920 | return; |
| 946 | 921 | } |
| 947 | 922 | |
| 923 | + // Bound recovery requests per user and per site to limit abuse. | |
| 924 | + $user_id = get_current_user_id(); | |
| 925 | + $user_rate_key = 'metasync_pw_recovery_user_' . $user_id; | |
| 926 | + $site_rate_key = 'metasync_pw_recovery_site'; | |
| 927 | + $this->refresh_recovery_state_cache($user_rate_key); | |
| 928 | + $this->refresh_recovery_state_cache($site_rate_key); | |
| 929 | + $user_attempts = (int) get_transient($user_rate_key); | |
| 930 | + $site_attempts = (int) get_transient($site_rate_key); | |
| 931 | + if ($user_attempts >= 3 || $site_attempts >= 10) { | |
| 932 | + $this->release_recovery_lock($rate_owner); | |
| 933 | + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429); | |
| 934 | + return; | |
| 935 | + } | |
| 936 | + | |
| 937 | + if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) { | |
| 938 | + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS); | |
| 939 | + else delete_transient($user_rate_key); | |
| 940 | + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS); | |
| 941 | + else delete_transient($site_rate_key); | |
| 942 | + $this->release_recovery_lock($rate_owner); | |
| 943 | + wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.')); | |
| 944 | + return; | |
| 945 | + } | |
| 946 | + | |
| 947 | + // Keep only a hash in the database; the raw token is sent once by email. | |
| 948 | + $token = bin2hex(random_bytes(32)); | |
| 949 | + $token_hash = hash('sha256', $token); | |
| 950 | + $reset_key = 'metasync_pw_reset_' . $token_hash; | |
| 951 | + if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) { | |
| 952 | + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS); | |
| 953 | + else delete_transient($user_rate_key); | |
| 954 | + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS); | |
| 955 | + else delete_transient($site_rate_key); | |
| 956 | + $this->release_recovery_lock($rate_owner); | |
| 957 | + $rate_owner = ''; | |
| 958 | + wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.')); | |
| 959 | + return; | |
| 960 | + } | |
| 961 | + $this->release_recovery_lock($rate_owner); | |
| 962 | + $rate_owner = ''; | |
| 963 | + | |
| 948 | 964 | $site_name = get_bloginfo('name'); |
| 949 | 965 | $site_url = home_url(); |
| 950 | 966 | $plugin_name = Metasync::get_effective_plugin_name(''); |
| 951 | - $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel'); | |
| 967 | + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token)); | |
| 952 | 968 | $to = $recovery_email; |
| 953 | 969 | |
| 954 | 970 | $subject = sprintf('[%s] Settings Password Recovery', $site_name); |
| 955 | 971 | |
| @@ -955,16 +971,14 @@ | ||
| 955 | 971 | |
| 956 | 972 | $message = sprintf( |
| 957 | 973 | "Hello,\n\n" . |
| 958 | 974 | "A password recovery request was made for the %s settings on %s.\n\n" . |
| 959 | - "Your Settings Password is:\n%s\n\n" . | |
| 960 | - "You can use this password to access the protected settings at:\n%s\n\n" . | |
| 975 | + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" . | |
| 961 | 976 | "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" . |
| 962 | 977 | "---\n" . |
| 963 | 978 | "This is an automated message from %s\n%s", |
| 964 | 979 | $plugin_name, |
| 965 | 980 | $site_name, |
| 966 | - $password, | |
| 967 | 981 | $settings_url, |
| 968 | 982 | $site_name, |
| 969 | 983 | $site_url |
| 970 | 984 | ); |
| @@ -977,13 +991,8 @@ | ||
| 977 | 991 | 'Content-Type: text/plain; charset=UTF-8', |
| 978 | 992 | sprintf('From: %s <%s>', $from_name, get_option('admin_email')) |
| 979 | 993 | ); |
| 980 | 994 | |
| 981 | - $mail_error = ''; | |
| 982 | - add_action('wp_mail_failed', function($error) use (&$mail_error) { | |
| 983 | - $mail_error = $error->get_error_message(); | |
| 984 | - }); | |
| 985 | - | |
| 986 | 995 | $sent = wp_mail($to, $subject, $message, $headers); |
| 987 | 996 | |
| 988 | 997 | if ($sent) { |
| 989 | 998 | wp_send_json_success(array( |
| @@ -989,21 +998,19 @@ | ||
| 989 | 998 | wp_send_json_success(array( |
| 990 | 999 | 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email)) |
| 991 | 1000 | )); |
| 992 | 1001 | } else { |
| 993 | - $error_message = 'Failed to send recovery email. '; | |
| 994 | - if (!empty($mail_error)) { | |
| 995 | - $error_message .= 'Error: ' . $mail_error; | |
| 996 | - } else { | |
| 997 | - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.'; | |
| 998 | - } | |
| 999 | - | |
| 1000 | - wp_send_json_error(array('message' => $error_message)); | |
| 1002 | + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.')); | |
| 1001 | 1003 | } |
| 1002 | 1004 | |
| 1003 | 1005 | } catch (Exception $e) { |
| 1006 | + // Always release the bounded issuance lock if an unexpected | |
| 1007 | + // exception occurs after it was acquired. | |
| 1008 | + if ($rate_owner !== '') { | |
| 1009 | + $this->release_recovery_lock($rate_owner); | |
| 1010 | + } | |
| 1004 | 1011 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 1005 | - error_log(sprintf( | |
| 1012 | + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 1006 | 1013 | 'MetaSync Password Recovery Error: %s in %s on line %d', |
| 1007 | 1014 | $e->getMessage(), |
| 1008 | 1015 | $e->getFile(), |
| 1009 | 1016 | $e->getLine() |
| @@ -1013,8 +1020,113 @@ | ||
| 1013 | 1020 | wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.')); |
| 1014 | 1021 | } |
| 1015 | 1022 | } |
| 1016 | 1023 | |
| 1024 | + /** Render and process the administrator-only password reset screen. */ | |
| 1025 | + public function render_password_reset_page() | |
| 1026 | + { | |
| 1027 | + if (!current_user_can('manage_options')) { | |
| 1028 | + wp_die('You do not have permission to reset this password.', '', array('response' => 403)); | |
| 1029 | + } | |
| 1030 | + | |
| 1031 | + $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : ''; | |
| 1032 | + $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : ''; | |
| 1033 | + $generation = (int) get_option('metasync_pw_reset_generation', 0); | |
| 1034 | + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : ''; | |
| 1035 | + $error = ''; | |
| 1036 | + $success = false; | |
| 1037 | + | |
| 1038 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 1039 | + if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) { | |
| 1040 | + $error = 'Security verification failed.'; | |
| 1041 | + } elseif ($token === '' || false === get_transient($transient_key)) { | |
| 1042 | + $error = 'This recovery link is invalid or has expired.'; | |
| 1043 | + } else { | |
| 1044 | + $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : ''; | |
| 1045 | + $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : ''; | |
| 1046 | + if (strlen($password) < 8) { | |
| 1047 | + $error = 'Password must be at least 8 characters long.'; | |
| 1048 | + } elseif (!hash_equals($password, $confirm)) { | |
| 1049 | + $error = 'Passwords do not match.'; | |
| 1050 | + } elseif (strpos($password, 'enc_v1:') === 0) { | |
| 1051 | + $error = 'Unable to save this password. Please choose a different password.'; | |
| 1052 | + } else { | |
| 1053 | + // add_option is an atomic insert in WordPress; it closes the | |
| 1054 | + // race where two requests attempt to redeem the same token. | |
| 1055 | + $lock_key = ''; | |
| 1056 | + if (!$this->acquire_recovery_lock($lock_key)) { | |
| 1057 | + $error = 'Another recovery request is being processed. Please wait a moment and try again.'; | |
| 1058 | + } else { | |
| 1059 | + register_shutdown_function(function () use (&$lock_key) { | |
| 1060 | + if ($lock_key !== '') { | |
| 1061 | + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key); | |
| 1062 | + } | |
| 1063 | + }); | |
| 1064 | + $this->refresh_recovery_state_cache($transient_key); | |
| 1065 | + // Refresh the settings version while holding the shared | |
| 1066 | + // lock. Token keys are independent so issuing or using | |
| 1067 | + // one link does not invalidate another unexpired link. | |
| 1068 | + $generation = (int) get_option('metasync_pw_reset_generation', 0); | |
| 1069 | + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : ''; | |
| 1070 | + // Re-check after claiming the lock. Consume only after | |
| 1071 | + // every required write succeeds, allowing a retry after | |
| 1072 | + // a temporary database or revocation failure. | |
| 1073 | + if (false === get_transient($transient_key)) { | |
| 1074 | + $error = 'This recovery link is invalid or has already been used.'; | |
| 1075 | + $this->release_recovery_lock($lock_key); | |
| 1076 | + $lock_key = ''; | |
| 1077 | + echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>'; | |
| 1078 | + return; | |
| 1079 | + } | |
| 1080 | + { | |
| 1081 | + $encrypted = Metasync::encrypt_secret($password); | |
| 1082 | + if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) { | |
| 1083 | + $error = 'Unable to securely save this password. Please try again later.'; | |
| 1084 | + } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) { | |
| 1085 | + $error = 'Unable to prepare the password reset. Please try again later.'; | |
| 1086 | + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */ | |
| 1087 | + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) { | |
| 1088 | + $error = 'Unable to revoke existing access. Please try again later.'; | |
| 1089 | + } elseif (!$this->persist_recovery_password($encrypted)) { | |
| 1090 | + $error = 'Unable to save the new password. Please try again later.'; | |
| 1091 | + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */ | |
| 1092 | + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) { | |
| 1093 | + $error = 'Unable to finalize access revocation. Please try again later.'; | |
| 1094 | + } elseif (!delete_transient($transient_key)) { | |
| 1095 | + $error = 'Unable to consume this recovery link. Please try again later.'; | |
| 1096 | + } else { | |
| 1097 | + $success = true; | |
| 1098 | + } | |
| 1099 | + } | |
| 1100 | + $this->release_recovery_lock($lock_key); | |
| 1101 | + $lock_key = ''; | |
| 1102 | + } | |
| 1103 | + } | |
| 1104 | + } | |
| 1105 | + } | |
| 1106 | + | |
| 1107 | + echo '<div class="wrap"><h1>Reset Settings Password</h1>'; | |
| 1108 | + if ($success) { | |
| 1109 | + echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>'; | |
| 1110 | + } else { | |
| 1111 | + if ($error !== '') { | |
| 1112 | + echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>'; | |
| 1113 | + } | |
| 1114 | + if ($token === '' || false === get_transient($transient_key)) { | |
| 1115 | + if ($error === '') { | |
| 1116 | + echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>'; | |
| 1117 | + } | |
| 1118 | + } else { | |
| 1119 | + echo '<form method="post" style="max-width:420px">'; | |
| 1120 | + wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce'); | |
| 1121 | + 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>'; | |
| 1122 | + 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>'; | |
| 1123 | + echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>'; | |
| 1124 | + } | |
| 1125 | + } | |
| 1126 | + echo '</div>'; | |
| 1127 | + } | |
| 1128 | + | |
| 1017 | 1129 | public function ajax_save_theme() |
| 1018 | 1130 | { |
| 1019 | 1131 | try { |
| 1020 | 1132 | # Verify nonce for security |
| @@ -1019,15 +1131,13 @@ | ||
| 1019 | 1131 | try { |
| 1020 | 1132 | # Verify nonce for security |
| 1021 | 1133 | if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) { |
| 1022 | 1134 | wp_send_json_error(array('message' => 'Security verification failed.')); |
| 1023 | - return; | |
| 1024 | 1135 | } |
| 1025 | 1136 | |
| 1026 | 1137 | # Check user capabilities |
| 1027 | 1138 | if (!Metasync::current_user_has_plugin_access()) { |
| 1028 | 1139 | wp_send_json_error(array('message' => 'Insufficient permissions.')); |
| 1029 | - return; | |
| 1030 | 1140 | } |
| 1031 | 1141 | |
| 1032 | 1142 | # Get and validate theme value |
| 1033 | 1143 | $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : ''; |
| @@ -1034,9 +1144,8 @@ | ||
| 1034 | 1144 | |
| 1035 | 1145 | # Validate theme is either 'light' or 'dark' |
| 1036 | 1146 | if (!in_array($theme, array('light', 'dark'), true)) { |
| 1037 | 1147 | wp_send_json_error(array('message' => 'Invalid theme value.')); |
| 1038 | - return; | |
| 1039 | 1148 | } |
| 1040 | 1149 | |
| 1041 | 1150 | # Save theme preference to WordPress options |
| 1042 | 1151 | update_option('metasync_theme', $theme, true); |
| @@ -1049,9 +1158,9 @@ | ||
| 1049 | 1158 | |
| 1050 | 1159 | } catch (Exception $e) { |
| 1051 | 1160 | # Log error if debug is enabled |
| 1052 | 1161 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 1053 | - error_log('MetaSync Theme Save Error: ' . $e->getMessage()); | |
| 1162 | + error_log('MetaSync Theme Save Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 1054 | 1163 | } |
| 1055 | 1164 | |
| 1056 | 1165 | wp_send_json_error(array('message' => 'Failed to save theme preference.')); |
| 1057 | 1166 | } |
| @@ -1062,9 +1171,8 @@ | ||
| 1062 | 1171 | check_ajax_referer('metasync_nonce', 'nonce'); |
| 1063 | 1172 | # Check user capabilities |
| 1064 | 1173 | if (!Metasync::current_user_has_plugin_access()) { |
| 1065 | 1174 | wp_send_json_error(['message' => 'Insufficient permissions']); |
| 1066 | - return; | |
| 1067 | 1175 | } |
| 1068 | 1176 | |
| 1069 | 1177 | # Get parameters |
| 1070 | 1178 | $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect'; |
| @@ -1193,9 +1301,9 @@ | ||
| 1193 | 1301 | wp_mkdir_p($temp_dir); |
| 1194 | 1302 | } |
| 1195 | 1303 | |
| 1196 | 1304 | # Generate unique filename |
| 1197 | - $timestamp = date('Y-m-d_H-i-s'); | |
| 1305 | + $timestamp = gmdate('Y-m-d_H-i-s'); | |
| 1198 | 1306 | $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip'; |
| 1199 | 1307 | $json_filename = 'whitelabel-settings.json'; |
| 1200 | 1308 | $zip_path = $temp_dir . '/' . $zip_filename; |
| 1201 | 1309 | |
| @@ -1256,14 +1364,14 @@ | ||
| 1256 | 1364 | header('Pragma: no-cache'); |
| 1257 | 1365 | header('Expires: 0'); |
| 1258 | 1366 | |
| 1259 | 1367 | # Output file and clean up |
| 1260 | - readfile($zip_path); | |
| 1261 | - unlink($zip_path); | |
| 1368 | + readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams a large file to output without loading it into memory | |
| 1369 | + wp_delete_file($zip_path); | |
| 1262 | 1370 | |
| 1263 | 1371 | # Clean up temp directory if empty |
| 1264 | 1372 | if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) { |
| 1265 | - rmdir($temp_dir); | |
| 1373 | + rmdir($temp_dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts | |
| 1266 | 1374 | } |
| 1267 | 1375 | |
| 1268 | 1376 | # Exit to prevent WordPress from adding anything to the response |
| 1269 | 1377 | exit; |
| @@ -1270,12 +1378,12 @@ | ||
| 1270 | 1378 | |
| 1271 | 1379 | } catch (Exception $e) { |
| 1272 | 1380 | # Log error if debug is enabled |
| 1273 | 1381 | if (defined('WP_DEBUG') && WP_DEBUG) { |
| 1274 | - error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); | |
| 1382 | + error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 1275 | 1383 | } |
| 1276 | 1384 | |
| 1277 | - wp_die('Failed to export whitelabel settings: ' . $e->getMessage()); | |
| 1385 | + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync')); | |
| 1278 | 1386 | } |
| 1279 | 1387 | } |
| 1280 | 1388 | |
| 1281 | 1389 | private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array()) |
| @@ -1320,10 +1428,10 @@ | ||
| 1320 | 1428 | public function render_html_pages_dashboard_widget() |
| 1321 | 1429 | { |
| 1322 | 1430 | global $wpdb; |
| 1323 | 1431 | |
| 1324 | - $query = " | |
| 1325 | - SELECT p.ID, p.post_title, p.post_modified, p.post_type | |
| 1432 | + $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 | |
| 1433 | + "SELECT p.ID, p.post_title, p.post_modified, p.post_type | |
| 1326 | 1434 | FROM {$wpdb->posts} p |
| 1327 | 1435 | INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id |
| 1328 | 1436 | WHERE p.post_status = 'publish' |
| 1329 | 1437 | AND p.post_type IN ('post', 'page') |
| @@ -1330,11 +1438,10 @@ | ||
| 1330 | 1438 | AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css') |
| 1331 | 1439 | GROUP BY p.ID |
| 1332 | 1440 | ORDER BY p.post_modified DESC |
| 1333 | 1441 | LIMIT 10 |
| 1334 | - "; | |
| 1335 | - | |
| 1336 | - $html_pages = $wpdb->get_results($query); | |
| 1442 | + " | |
| 1443 | + ); | |
| 1337 | 1444 | $total_count = count($html_pages); |
| 1338 | 1445 | |
| 1339 | 1446 | $label = $this->get_html_source_label(); |
| 1340 | 1447 | |
| @@ -1341,13 +1448,14 @@ | ||
| 1341 | 1448 | if (empty($html_pages)) { |
| 1342 | 1449 | echo '<div class="metasync-dashboard-widget-empty">'; |
| 1343 | 1450 | echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>'; |
| 1344 | 1451 | echo '<p style="text-align: center; color: #666;">'; |
| 1452 | + /* translators: %s: HTML source label. */ | |
| 1345 | 1453 | echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>'); |
| 1346 | 1454 | echo '</p>'; |
| 1347 | 1455 | echo '<p style="text-align: center;">'; |
| 1348 | - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">'; | |
| 1349 | - echo __('Get Started', 'metasync'); | |
| 1456 | + echo '<a href="' . esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug)) . '" class="button button-primary">'; | |
| 1457 | + echo esc_html__('Get Started', 'metasync'); | |
| 1350 | 1458 | echo '</a>'; |
| 1351 | 1459 | echo '</p>'; |
| 1352 | 1460 | echo '</div>'; |
| 1353 | 1461 | return; |
| @@ -1356,15 +1464,15 @@ | ||
| 1356 | 1464 | echo '<div class="metasync-dashboard-widget">'; |
| 1357 | 1465 | |
| 1358 | 1466 | echo '<div class="metasync-widget-stats">'; |
| 1359 | 1467 | echo '<div class="metasync-stat-box">'; |
| 1360 | - echo '<span class="metasync-stat-number">' . $total_count . '</span>'; | |
| 1361 | - echo '<span class="metasync-stat-label">' . __('AI-Generated Pages', 'metasync') . '</span>'; | |
| 1468 | + echo '<span class="metasync-stat-number">' . esc_html((string) $total_count) . '</span>'; | |
| 1469 | + echo '<span class="metasync-stat-label">' . esc_html__('AI-Generated Pages', 'metasync') . '</span>'; | |
| 1362 | 1470 | echo '</div>'; |
| 1363 | 1471 | echo '</div>'; |
| 1364 | 1472 | |
| 1365 | 1473 | echo '<div class="metasync-widget-list">'; |
| 1366 | - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>'; | |
| 1474 | + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>'; | |
| 1367 | 1475 | echo '<ul>'; |
| 1368 | 1476 | |
| 1369 | 1477 | foreach ($html_pages as $page) { |
| 1370 | 1478 | $edit_link = get_edit_post_link($page->ID); |
| @@ -1377,11 +1485,12 @@ | ||
| 1377 | 1485 | echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">'; |
| 1378 | 1486 | echo esc_html($page->post_title ?: __('(no title)', 'metasync')); |
| 1379 | 1487 | echo '</a>'; |
| 1380 | 1488 | echo '<span class="metasync-page-meta">'; |
| 1381 | - echo sprintf(__('Updated %s ago', 'metasync'), $time_ago); | |
| 1489 | + /* translators: %s: human-readable time difference. */ | |
| 1490 | + echo esc_html(sprintf(__('Updated %s ago', 'metasync'), $time_ago)); | |
| 1382 | 1491 | echo ' • '; |
| 1383 | - echo '<a href="' . esc_url($view_link) . '" target="_blank">' . __('View', 'metasync') . '</a>'; | |
| 1492 | + echo '<a href="' . esc_url($view_link) . '" target="_blank">' . esc_html__('View', 'metasync') . '</a>'; | |
| 1384 | 1493 | echo '</span>'; |
| 1385 | 1494 | echo '</div>'; |
| 1386 | 1495 | echo '</li>'; |
| 1387 | 1496 | } |
| @@ -1389,10 +1498,10 @@ | ||
| 1389 | 1498 | echo '</ul>'; |
| 1390 | 1499 | echo '</div>'; |
| 1391 | 1500 | |
| 1392 | 1501 | echo '<div class="metasync-widget-footer">'; |
| 1393 | - echo '<a href="' . admin_url('edit.php?post_type=page') . '">'; | |
| 1394 | - echo __('View All Pages', 'metasync') . ' →'; | |
| 1502 | + echo '<a href="' . esc_url(admin_url("edit.php?post_type=page")) . '">'; | |
| 1503 | + echo esc_html__('View All Pages', 'metasync') . ' →'; | |
| 1395 | 1504 | echo '</a>'; |
| 1396 | 1505 | echo '</div>'; |
| 1397 | 1506 | |
| 1398 | 1507 | echo '</div>'; |