| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | <?php |
| 2 | +// phpcs:set WordPress.Security.ValidatedSanitizedInput customSanitizingFunctions[] ph_clean | |
| 3 | +// ph_clean() recursively sanitizes text; presence, shape and unslashing checks remain separate. | |
| 4 | + | |
| 2 | 5 | /** |
| 3 | 6 | * PropertyHive Admin Merge Duplicate Contacts Class. |
| 4 | 7 | * |
| 5 | 8 | * @author PropertyHive |
| @@ -14,8 +17,9 @@ | ||
| 14 | 17 | |
| 15 | 18 | /** |
| 16 | 19 | * PH_Admin_Merge_Contacts |
| 17 | 20 | */ |
| 21 | +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- Legacy public global class PH_Admin_Merge_Contacts; preserving the existing PH_* class name is required for plugin and extension compatibility. | |
| 18 | 22 | class PH_Admin_Merge_Contacts { |
| 19 | 23 | |
| 20 | 24 | /** |
| 21 | 25 | * Handles the display of contacts to merge, to allow the user to choose a primary contact |
| @@ -24,8 +28,25 @@ | ||
| 24 | 28 | * @return void |
| 25 | 29 | */ |
| 26 | 30 | public function output() |
| 27 | 31 | { |
| 32 | + // This endpoint only renders the merge review screen. The state-changing merge | |
| 33 | + // request is sent to the separate AJAX handler with its own nonce and capability checks. | |
| 34 | + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The GET value only selects the read-only merge review screen; the AJAX mutation verifies its nonce. | |
| 35 | + $request_get = wp_unslash( $_GET ); | |
| 36 | + $ids_to_merge = array(); | |
| 37 | + $merge_ids_input = ( isset( $request_get['merge_ids'] ) && is_scalar( $request_get['merge_ids'] ) ) ? sanitize_text_field( $request_get['merge_ids'] ) : ''; | |
| 38 | + if ( $merge_ids_input !== '' ) { | |
| 39 | + foreach ( explode( '|', $merge_ids_input ) as $merge_id_input ) { | |
| 40 | + $merge_id = absint( $merge_id_input ); | |
| 41 | + if ( $merge_id && 'contact' === get_post_type( $merge_id ) ) { | |
| 42 | + $ids_to_merge[] = $merge_id; | |
| 43 | + } | |
| 44 | + } | |
| 45 | + $ids_to_merge = array_values( array_unique( $ids_to_merge ) ); | |
| 46 | + } | |
| 47 | + $merge_ids_for_request = implode( '|', $ids_to_merge ); | |
| 48 | + | |
| 28 | 49 | ?> |
| 29 | 50 | <div class="wrap propertyhive"> |
| 30 | 51 | |
| 31 | 52 | <h1><?php echo esc_html(__('Merge Contacts', 'propertyhive')); ?></h1> |
| @@ -34,14 +55,8 @@ | ||
| 34 | 55 | |
| 35 | 56 | <p><strong>Note:</strong> This action is irreversible.</p> |
| 36 | 57 | |
| 37 | 58 | <?php |
| 38 | - | |
| 39 | - if ( isset( $_GET['merge_ids'] ) && $_GET['merge_ids'] != '' ) | |
| 40 | - { | |
| 41 | - $ids_to_merge = explode( '|', ph_clean( $_GET['merge_ids']) ); | |
| 42 | - } | |
| 43 | - | |
| 44 | 59 | if ( isset( $ids_to_merge ) && count( $ids_to_merge ) > 1 ) |
| 45 | 60 | { |
| 46 | 61 | foreach ( $ids_to_merge as $i => $contact_id ) |
| 47 | 62 | { |
| @@ -120,10 +135,13 @@ | ||
| 120 | 135 | $contact_parts = $this->get_tenancy_records( $contact, $contact_parts ); |
| 121 | 136 | |
| 122 | 137 | $contact_parts = $this->get_note_records( $contact, $contact_parts ); |
| 123 | 138 | |
| 139 | + // Stored contact details may contain markup; sanitize before the trusted extension HTML filter. | |
| 140 | + $contact_parts = array_map( 'wp_kses_post', $contact_parts ); | |
| 124 | 141 | $contact_parts = apply_filters( 'propertyhive_merge_contact_parts', $contact_parts ); |
| 125 | 142 | |
| 143 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built-in contact summary HTML is sanitized immediately before the trusted propertyhive_merge_contact_parts extension filter. | |
| 126 | 144 | echo implode( '<br>', $contact_parts ); |
| 127 | 145 | ?> |
| 128 | 146 | <label style="position:absolute; right:25px; top:25px;"> |
| 129 | 147 | <?php echo esc_html(__( 'Use as Primary Contact', 'propertyhive' )); ?> |
| @@ -160,11 +178,11 @@ | ||
| 160 | 178 | jQuery('#merge_contacts_button').attr('disabled', 'disabled'); |
| 161 | 179 | |
| 162 | 180 | var data = { |
| 163 | 181 | action: 'propertyhive_merge_contact_records', |
| 164 | - contact_ids : '<?php echo esc_js(ph_clean($_GET['merge_ids'])); ?>', | |
| 182 | + contact_ids : '<?php echo esc_js( $merge_ids_for_request ); ?>', | |
| 165 | 183 | primary_contact_id: selected_primary, |
| 166 | - nonce: '<?php echo wp_create_nonce( 'propertyhive_merge_contact' ); ?>', | |
| 184 | + nonce: '<?php echo esc_js( wp_create_nonce( 'propertyhive_merge_contact' ) ); ?>', | |
| 167 | 185 | }; |
| 168 | 186 | |
| 169 | 187 | jQuery.post( '<?php echo esc_url(admin_url('admin-ajax.php')); ?>', data, function(response) { |
| 170 | 188 | |
| @@ -177,9 +195,9 @@ | ||
| 177 | 195 | } |
| 178 | 196 | if (response.success) |
| 179 | 197 | { |
| 180 | 198 | // Redirect to referrer, adding message in admin_notices |
| 181 | - window.location.href = '<?php echo admin_url('edit.php?post_type=contact&propertyhive_contacts_merged=1'); ?>'; | |
| 199 | + window.location.href = <?php echo wp_json_encode( admin_url( 'edit.php?post_type=contact&propertyhive_contacts_merged=1' ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); ?>; | |
| 182 | 200 | } |
| 183 | 201 | }); |
| 184 | 202 | } |
| 185 | 203 | }); |
| @@ -211,8 +229,9 @@ | ||
| 211 | 229 | // get properties where this is the owner |
| 212 | 230 | $args = array( |
| 213 | 231 | 'post_type' => 'property', |
| 214 | 232 | 'nopaging' => true, |
| 233 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 215 | 234 | 'meta_query' => array( |
| 216 | 235 | 'relation' => 'OR', |
| 217 | 236 | array( |
| 218 | 237 | 'key' => '_owner_contact_id', |
| @@ -261,8 +280,9 @@ | ||
| 261 | 280 | // get appraisals where this is the owner and where not instructed |
| 262 | 281 | $args = array( |
| 263 | 282 | 'post_type' => 'appraisal', |
| 264 | 283 | 'nopaging' => true, |
| 284 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 265 | 285 | 'meta_query' => array( |
| 266 | 286 | array( |
| 267 | 287 | 'key' => '_property_owner_contact_id', |
| 268 | 288 | 'value' => $contact->id, |
| @@ -434,10 +454,12 @@ | ||
| 434 | 454 | } |
| 435 | 455 | |
| 436 | 456 | $args = array( |
| 437 | 457 | 'post_type' => 'enquiry', |
| 438 | - 'nopaging' => true, | |
| 458 | + 'posts_per_page' => 1, | |
| 459 | + 'no_found_rows' => false, | |
| 439 | 460 | 'fields' => 'ids', |
| 461 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-ID query retains found_rows for the complete related-enquiry count. | |
| 440 | 462 | 'meta_query' => $meta_query, |
| 441 | 463 | ); |
| 442 | 464 | $enquiries_query = new WP_Query( $args ); |
| 443 | 465 | $enquiries_count = $enquiries_query->found_posts; |
| @@ -461,8 +483,9 @@ | ||
| 461 | 483 | $args = array( |
| 462 | 484 | 'post_type' => 'viewing', |
| 463 | 485 | 'posts_per_page' => 1, |
| 464 | 486 | 'fields' => 'ids', |
| 487 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Viewing/offer/sale/tenancy merge summaries use posts_per_page=1, fields=ids and found_posts to display counts for one contact. Each query has a single _applicant_contact_id equality and posts_per_page=1; found_rows remains enabled for the count. | |
| 465 | 488 | 'meta_query' => array( |
| 466 | 489 | array( |
| 467 | 490 | 'key' => '_applicant_contact_id', |
| 468 | 491 | 'value' => $contact->id, |
| @@ -491,8 +514,9 @@ | ||
| 491 | 514 | $args = array( |
| 492 | 515 | 'post_type' => 'offer', |
| 493 | 516 | 'posts_per_page' => 1, |
| 494 | 517 | 'fields' => 'ids', |
| 518 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Viewing/offer/sale/tenancy merge summaries use posts_per_page=1, fields=ids and found_posts to display counts for one contact. Each query has a single _applicant_contact_id equality and posts_per_page=1; found_rows remains enabled for the count. | |
| 495 | 519 | 'meta_query' => array( |
| 496 | 520 | array( |
| 497 | 521 | 'key' => '_applicant_contact_id', |
| 498 | 522 | 'value' => $contact->id, |
| @@ -521,8 +545,9 @@ | ||
| 521 | 545 | $args = array( |
| 522 | 546 | 'post_type' => 'sale', |
| 523 | 547 | 'posts_per_page' => 1, |
| 524 | 548 | 'fields' => 'ids', |
| 549 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Viewing/offer/sale/tenancy merge summaries use posts_per_page=1, fields=ids and found_posts to display counts for one contact. Each query has a single _applicant_contact_id equality and posts_per_page=1; found_rows remains enabled for the count. | |
| 525 | 550 | 'meta_query' => array( |
| 526 | 551 | array( |
| 527 | 552 | 'key' => '_applicant_contact_id', |
| 528 | 553 | 'value' => $contact->id, |
| @@ -551,8 +576,9 @@ | ||
| 551 | 576 | $args = array( |
| 552 | 577 | 'post_type' => 'tenancy', |
| 553 | 578 | 'posts_per_page' => 1, |
| 554 | 579 | 'fields' => 'ids', |
| 580 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Viewing/offer/sale/tenancy merge summaries use posts_per_page=1, fields=ids and found_posts to display counts for one contact. Each query has a single _applicant_contact_id equality and posts_per_page=1; found_rows remains enabled for the count. | |
| 555 | 581 | 'meta_query' => array( |
| 556 | 582 | array( |
| 557 | 583 | 'key' => '_applicant_contact_id', |
| 558 | 584 | 'value' => $contact->id |
| @@ -584,8 +610,9 @@ | ||
| 584 | 610 | |
| 585 | 611 | $args = array( |
| 586 | 612 | 'post_id' => (int)$contact->id, |
| 587 | 613 | 'type' => 'propertyhive_note', |
| 614 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 588 | 615 | 'meta_query' => array( |
| 589 | 616 | array( |
| 590 | 617 | 'key' => 'related_to', |
| 591 | 618 | 'value' => '"' . (int)$contact->id . '"', |
| @@ -718,8 +745,9 @@ | ||
| 718 | 745 | // Move appraisal records to primary |
| 719 | 746 | $args = array( |
| 720 | 747 | 'post_type' => 'appraisal', |
| 721 | 748 | 'nopaging' => true, |
| 749 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 722 | 750 | 'meta_query' => array( |
| 723 | 751 | array( |
| 724 | 752 | 'key' => '_property_owner_contact_id', |
| 725 | 753 | 'value' => $contacts_to_merge, |
| @@ -748,8 +776,9 @@ | ||
| 748 | 776 | // Move property owner records to primary |
| 749 | 777 | $args = array( |
| 750 | 778 | 'post_type' => 'property', |
| 751 | 779 | 'nopaging' => true, |
| 780 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 752 | 781 | 'meta_query' => array( |
| 753 | 782 | 'relation' => 'OR', |
| 754 | 783 | array( |
| 755 | 784 | 'key' => '_owner_contact_id', |
| @@ -797,8 +826,9 @@ | ||
| 797 | 826 | $args = array( |
| 798 | 827 | 'post_type' => 'enquiry', |
| 799 | 828 | 'nopaging' => true, |
| 800 | 829 | 'fields' => 'ids', |
| 830 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 801 | 831 | 'meta_query' => array( |
| 802 | 832 | array( |
| 803 | 833 | 'key' => '_contact_id', |
| 804 | 834 | 'value' => $contacts_to_merge, |
| @@ -825,8 +855,9 @@ | ||
| 825 | 855 | $args = array( |
| 826 | 856 | 'post_type' => 'viewing', |
| 827 | 857 | 'nopaging' => true, |
| 828 | 858 | 'fields' => 'ids', |
| 859 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 829 | 860 | 'meta_query' => array( |
| 830 | 861 | array( |
| 831 | 862 | 'key' => '_applicant_contact_id', |
| 832 | 863 | 'value' => $contacts_to_merge, |
| @@ -853,8 +884,9 @@ | ||
| 853 | 884 | $args = array( |
| 854 | 885 | 'post_type' => 'offer', |
| 855 | 886 | 'nopaging' => true, |
| 856 | 887 | 'fields' => 'ids', |
| 888 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 857 | 889 | 'meta_query' => array( |
| 858 | 890 | array( |
| 859 | 891 | 'key' => '_applicant_contact_id', |
| 860 | 892 | 'value' => $contacts_to_merge, |
| @@ -881,8 +913,9 @@ | ||
| 881 | 913 | $args = array( |
| 882 | 914 | 'post_type' => 'sale', |
| 883 | 915 | 'nopaging' => true, |
| 884 | 916 | 'fields' => 'ids', |
| 917 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 885 | 918 | 'meta_query' => array( |
| 886 | 919 | array( |
| 887 | 920 | 'key' => '_applicant_contact_id', |
| 888 | 921 | 'value' => $contacts_to_merge, |
| @@ -909,8 +942,9 @@ | ||
| 909 | 942 | $args = array( |
| 910 | 943 | 'post_type' => 'tenancy', |
| 911 | 944 | 'nopaging' => true, |
| 912 | 945 | 'fields' => 'ids', |
| 946 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 913 | 947 | 'meta_query' => array( |
| 914 | 948 | array( |
| 915 | 949 | 'key' => '_applicant_contact_id', |
| 916 | 950 | 'value' => $contacts_to_merge, |
| @@ -940,8 +974,9 @@ | ||
| 940 | 974 | |
| 941 | 975 | $args = array( |
| 942 | 976 | 'post_id' => (int)$child_contact_id, |
| 943 | 977 | 'type' => 'propertyhive_note', |
| 978 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Contact merge must find every record linked through contact metadata so summaries and reassignment do not omit relationships. | |
| 944 | 979 | 'meta_query' => array( |
| 945 | 980 | array( |
| 946 | 981 | 'key' => 'related_to', |
| 947 | 982 | 'value' => '"' . (int)$child_contact_id . '"', |
| @@ -1010,9 +1045,9 @@ | ||
| 1010 | 1045 | 'comment_post_ID' => (int)$child_contact_id, |
| 1011 | 1046 | 'comment_author' => $current_user->display_name, |
| 1012 | 1047 | 'comment_author_email' => '[email protected]', |
| 1013 | 1048 | 'comment_author_url' => '', |
| 1014 | - 'comment_date' => date("Y-m-d H:i:s"), | |
| 1049 | + 'comment_date' => gmdate("Y-m-d H:i:s"), | |
| 1015 | 1050 | 'comment_content' => serialize($comment), |
| 1016 | 1051 | 'comment_approved' => 1, |
| 1017 | 1052 | 'comment_type' => 'propertyhive_note', |
| 1018 | 1053 | ); |
| @@ -1024,15 +1059,16 @@ | ||
| 1024 | 1059 | |
| 1025 | 1060 | // Update email log |
| 1026 | 1061 | foreach ( $contacts_to_merge as $child_contact_id ) |
| 1027 | 1062 | { |
| 1028 | - $wpdb->query(" | |
| 1029 | - UPDATE " . $wpdb->prefix . "ph_email_log | |
| 1030 | - SET | |
| 1031 | - contact_id = '" . (int)$primary_contact_id . "' | |
| 1032 | - WHERE | |
| 1033 | - contact_id = '" . (int)$child_contact_id . "' | |
| 1034 | - "); | |
| 1063 | + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This writes the plugin-owned email log relationship during a verified merge; no cached read result is used. | |
| 1064 | + $wpdb->update( | |
| 1065 | + $wpdb->prefix . 'ph_email_log', | |
| 1066 | + array( 'contact_id' => (int) $primary_contact_id ), | |
| 1067 | + array( 'contact_id' => (int) $child_contact_id ), | |
| 1068 | + array( '%d' ), | |
| 1069 | + array( '%d' ) | |
| 1070 | + ); | |
| 1035 | 1071 | } |
| 1036 | 1072 | |
| 1037 | 1073 | do_action( 'propertyhive_contacts_merged', $primary_contact_id, $contacts_to_merge ); |
| 1038 | 1074 | } |
| @@ -1037,5 +1073,5 @@ | ||
| 1037 | 1073 | do_action( 'propertyhive_contacts_merged', $primary_contact_id, $contacts_to_merge ); |
| 1038 | 1074 | } |
| 1039 | 1075 | } |
| 1040 | 1076 | |
| 1041 | -endif; | |
| 1077 | +endif; | |