PluginProbe
Property Hive / 2.3.1
Property Hive v2.3.1
2.3.1 2.3.0 2.2.6 2.2.5 2.2.4 2.2.3 2.2.2 1.4.46 1.4.47 1.4.48 1.4.49 1.4.5 1.4.50 1.4.51 1.4.52 1.4.53 1.4.54 1.4.55 1.4.56 1.4.57 1.4.58 1.4.59 1.4.6 1.4.60 1.4.61 All 261 releases
← All changes | propertyhive.php +298 -81 1.4.472.3.1 View file →
@@ -1,17 +1,18 @@
1 1 <?php
2 2 /**
3 3 * Plugin Name: Property Hive
4 4 * Plugin URI: https://wordpress.org/plugins/propertyhive/
5 - * Description: Estate Agency Property Software Plugin for WordPress
6 - * Version: 1.4.47
5 + * Description: Property Hive has everything you need to build estate agency websites
6 + * Version: 2.3.1
7 7 * Author: PropertyHive
8 8 * Author URI: https://wp-property-hive.com
9 - * Requires at least: 3.8
10 - * Tested up to: 5.2.3
9 + * License: GPLv3
10 + * License URI: https://www.gnu.org/licenses/gpl-3.0.html
11 + * Requires at least: 5.6
12 + * Tested up to: 7.1
11 13 *
12 14 * Text Domain: propertyhive
13 - * Domain Path: /i18n/languages/
14 15 *
15 16 * @package PropertyHive
16 17 * @category Core
17 18 * @author PropertyHive
@@ -26,9 +27,9 @@
26 27 /**
27 28 * Main PropertyHive Class
28 29 *
29 30 * @class PropertyHive
30 - * @version 1.4.47
31 + * @version 2.3.1
31 32 */
32 33 final class PropertyHive {
33 34
34 35 /**
@@ -33,9 +34,9 @@
33 34
34 35 /**
35 36 * @var string
36 37 */
37 - public $version = '1.4.47';
38 + public $version = '2.3.1';
38 39
39 40 /**
40 41 * @var PropertyHive The single instance of the class
41 42 */
@@ -48,13 +49,34 @@
48 49 */
49 50 public $query = null;
50 51
51 52 /**
53 + * REST API instance.
54 + *
55 + * @var PH_Rest_Api
56 + */
57 + public $rest_api = null;
58 +
59 + /**
52 60 * Email instance.
53 61 *
54 62 * @var PH_Emails
55 63 */
56 64 public $email = null;
65 +
66 + /**
67 + * License instance.
68 + *
69 + * @var PH_Licenses
70 + */
71 + public $license = null;
72 +
73 + /**
74 + * Countries instance.
75 + *
76 + * @var PH_Countries
77 + */
78 + public $countries = null;
57 79
58 80 /**
59 81 * Main PropertyHive Instance
60 82 *
@@ -77,9 +99,9 @@
77 99 *
78 100 * @since 1.0.0
79 101 */
80 102 public function __clone() {
81 - _doing_it_wrong( __FUNCTION__, __( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
103 + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
82 104 }
83 105
84 106 /**
85 107 * Unserializing instances of this class is forbidden.
@@ -86,9 +108,9 @@
86 108 *
87 109 * @since 1.0.0
88 110 */
89 111 public function __wakeup() {
90 - _doing_it_wrong( __FUNCTION__, __( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
112 + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin&#8217; huh?', 'propertyhive' ), '1.0.0' );
91 113 }
92 114
93 115 /**
94 116 * PropertyHive Constructor.
@@ -109,13 +131,11 @@
109 131
110 132 // Include required files
111 133 $this->includes();
112 134
113 - // Init API
114 - //$this->api = new PH_API();
115 -
116 135 // Hooks
117 136 add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), array( $this, 'action_links' ) );
137 + add_filter( 'propertyhive_departments', array( $this, 'setup_custom_departments' ) );
118 138 //add_action( 'widgets_init', array( $this, 'include_widgets' ) );
119 139 add_action( 'init', array( $this, 'init' ), 0 );
120 140 add_action( 'init', array( $this, 'include_template_functions' ) );
121 141 add_action( 'init', array( $this, 'unsubscribe_contact' ), 0 );
@@ -121,12 +141,67 @@
121 141 add_action( 'init', array( $this, 'unsubscribe_contact' ), 0 );
122 142 add_action( 'init', array( 'PH_Shortcodes', 'init' ) );
123 143 add_action( 'rest_api_init', array( $this, 'rest_api_includes' ) );
124 144 add_action( 'after_setup_theme', array( $this, 'setup_environment' ) );
145 + add_action( 'wp', array( $this, 'set_cache_constants' ) );
146 + add_action( 'wp_update_comment_count', array( $this, 'exclude_notes_from_comment_count' ) );
147 +
148 + // Ensure Template Assistant add on is deactivated now the code is merged into core
149 + add_action('plugins_loaded', function () {
150 + propertyhive_deactivate_template_assistant();
151 + }, 1);
125 152
126 153 // Loaded action
127 154 do_action( 'propertyhive_loaded' );
128 155 }
156 +
157 + public function set_cache_constants()
158 + {
159 + $page_ids = array_filter( array( ph_get_page_id( 'my_account' ) ) );
160 +
161 + if ( !empty($page_ids) && is_page( $page_ids ) )
162 + {
163 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress/cache-plugin integration constant DONOTCACHEPAGE; cache integrations depend on this established global constant name.
164 + if ( !defined('DONOTCACHEPAGE') ) { define('DONOTCACHEPAGE', TRUE); }
165 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress/cache-plugin integration constant DONOTCACHEOBJECT; cache integrations depend on this established global constant name.
166 + if ( !defined('DONOTCACHEOBJECT') ) { define('DONOTCACHEOBJECT', TRUE); }
167 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- WordPress/cache-plugin integration constant DONOTCACHEDB; cache integrations depend on this established global constant name.
168 + if ( !defined('DONOTCACHEDB') ) { define('DONOTCACHEDB', TRUE); }
169 + }
170 + }
171 +
172 + public function setup_custom_departments( $departments )
173 + {
174 + $custom_departments = ph_get_custom_departments();
175 +
176 + foreach ( $custom_departments as $key => $custom_department )
177 + {
178 + $departments[$key] = $custom_department['name'];
179 + }
180 +
181 + return $departments;
182 + }
183 +
184 + public function exclude_notes_from_comment_count($post_id) {
185 + global $wpdb;
186 + $post_id = (int)$post_id;
187 + if ( !$post_id ) {
188 + return false;
189 + }
190 + if ( !$post = get_post($post_id) ) {
191 + return false;
192 + }
193 +
194 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Recount immediately after comment changes, excluding internal CRM notes; a cached count would be stale at this mutation boundary.
195 + $new = (int) $wpdb->get_var( $wpdb->prepare("SELECT COUNT(*)
196 + FROM $wpdb->comments
197 + WHERE comment_post_ID = %d AND comment_approved = '1' AND comment_type != 'propertyhive_note' ", $post_id) );
198 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Core comment_count needs the recalculated non-note count; clean_post_cache immediately below invalidates the affected post.
199 + $wpdb->update( $wpdb->posts, array('comment_count' => $new), array('ID' => $post_id) );
200 +
201 + clean_post_cache( $post );
202 + }
203 +
129 204
130 205 /**
131 206 * Show action links on the plugin screen
132 207 *
@@ -136,10 +211,10 @@
136 211 public function action_links( $links )
137 212 {
138 213 return array_merge( array(
139 214 '<a href="' . admin_url( 'admin.php?page=ph-settings' ) . '">' . __( 'Settings', 'propertyhive' ) . '</a>',
215 + '<a href="' . esc_url( apply_filters( 'propertyhive_features_url', admin_url( 'admin.php?page=ph-settings&tab=features' ) ) ) . '">' . __( 'Features', 'propertyhive' ) . '</a>',
140 216 '<a href="' . esc_url( apply_filters( 'propertyhive_url', 'https://wp-property-hive.com/', 'propertyhive' ) ) . '" target="_blank">' . __( 'Website', 'propertyhive' ) . '</a>',
141 - '<a href="' . esc_url( apply_filters( 'propertyhive_addons_url', 'https://wp-property-hive.com/add-ons' ) ) . '" target="_blank">' . __( 'Add Ons', 'propertyhive' ) . '</a>',
142 217 ), $links );
143 218 }
144 219
145 220 /**
@@ -182,12 +257,15 @@
182 257 * Define PH Constants
183 258 */
184 259 private function define_constants()
185 260 {
261 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Legacy public PH_* constants are consumed by existing themes and add-ons.
186 262 define( 'PH_PLUGIN_FILE', __FILE__ );
263 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Preserve the existing public version constant used by add-ons.
187 264 define( 'PH_VERSION', $this->version );
188 265
189 266 if ( ! defined( 'PH_TEMPLATE_PATH' ) ) {
267 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Preserve the existing theme-overridable template path constant.
190 268 define( 'PH_TEMPLATE_PATH', $this->template_path() );
191 269 }
192 270 }
193 271
@@ -195,8 +273,9 @@
195 273 * Include required core files used in admin and on the frontend.
196 274 */
197 275 private function includes() {
198 276 include_once( 'includes/ph-core-functions.php' );
277 + include_once( 'includes/ph-update-functions.php' );
199 278 include_once( 'includes/class-ph-install.php' );
200 279 include_once( 'includes/class-ph-comments.php' );
201 280 include_once( 'includes/class-ph-emails.php' );
202 281 include_once( 'includes/class-ph-licenses.php' );
@@ -209,9 +288,10 @@
209 288 if ( defined( 'DOING_AJAX' ) ) {
210 289 $this->ajax_includes();
211 290 }
212 291
213 - if ( ! is_admin() || defined( 'DOING_AJAX' ) ) {
292 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only Elementor editor detection selects frontend includes; it does not change saved data.
293 + if ( ! is_admin() || defined( 'DOING_AJAX' ) || ( isset( $_GET['action'] ) && is_string( $_GET['action'] ) && 'elementor' === $_GET['action'] ) ) {
214 294 $this->frontend_includes();
215 295 }
216 296
217 297 include_once( 'includes/ph-form-functions.php' ); // Form Renderers
@@ -221,12 +301,32 @@
221 301 include( 'includes/class-ph-query.php' ); // The main query class
222 302
223 303 include_once( 'includes/class-ph-post-types.php' ); // Registers post types
224 304 include_once( 'includes/class-ph-countries.php' ); // Manages interaction with countries and currency
225 -
305 +
306 + if ( get_option( 'propertyhive_address_keyword_compare', '=' ) == 'polygon' )
307 + {
308 + include_once( 'includes/class-ph-address-keyword-polygon.php' ); // Manages getting and caching polygons associated with search terms
309 + }
310 +
226 311 include_once( 'includes/class-ph-user-contacts.php' ); // Handles keeping contacts and users in sync
227 312
313 + include_once( 'includes/class-ph-avada.php' ); // Avada / Fusion Builder
314 + include_once( 'includes/class-ph-bricks-builder.php' ); // Bricks Builder
315 + include_once( 'includes/class-ph-divi.php' ); // Divi
316 + include_once( 'includes/class-ph-elementor.php' ); // Elementor
317 + include_once( 'includes/class-ph-salient.php' ); // Salient / WPBakery
228 318 include_once( 'includes/class-ph-yoast-seo.php' ); // Yoast SEO
319 + include_once( 'includes/class-ph-rank-math.php' ); // Rank Math
320 + include_once( 'includes/class-ph-aioseo.php' ); // All In One SEO
321 + include_once( 'includes/class-ph-duplicate-post.php' ); // Duplicate Post
322 +
323 + include_once( 'includes/class-ph-search-analytics.php' ); // Search Analytics
324 +
325 + include_once( 'includes/class-ph-additional-fields.php' ); // Additional Fields
326 + include_once( 'includes/class-ph-text-substitution.php' ); // Text Substitution
327 +
328 + include_once( 'includes/ph-pro-feature-functions.php' ); // Pro Features
229 329
230 330 $this->query = new PH_Query();
231 331 $this->email = new PH_Emails();
232 332 $this->license = new PH_Licenses();
@@ -269,71 +369,174 @@
269 369 include_once( 'includes/widgets/class-ph-widget-properties.php' );*/
270 370 }
271 371
272 372 /**
273 - * Unsubscribe contact if ph_unsubscribe param set in query string. Might be a better place for this
373 + * Contacts may store several comma-separated mailbox addresses.
274 374 */
275 - public function unsubscribe_contact() {
276 - if ( isset($_GET['ph_unsubscribe']) && !empty($_GET['ph_unsubscribe']) )
277 - {
278 - $ph_unsubscribe = sanitize_text_field(base64_decode($_GET['ph_unsubscribe']));
279 - if ( $ph_unsubscribe === FALSE )
280 - {
281 - die("Invalid token passed 1");
282 - return false;
375 + private function contact_unsubscribe_recipients( $email ) {
376 + if ( ! is_string( $email ) || '' === $email ) {
377 + return array();
378 + }
379 + $recipients = array_map( 'trim', explode( ',', $email ) );
380 + foreach ( $recipients as $recipient ) {
381 + if ( ! is_email( $recipient ) ) {
382 + return array();
283 383 }
384 + }
385 + return array_values( array_unique( $recipients ) );
386 + }
284 387
285 - $explode_ph_unsubscribe = explode("|", $ph_unsubscribe);
286 - if ( count($explode_ph_unsubscribe) != 2 )
287 - {
288 - die("Invalid token passed 2");
289 - return false;
290 - }
388 + /**
389 + * Build a durable, email-bound unsubscribe link for an existing contact.
390 + */
391 + public function get_contact_unsubscribe_url( $contact_id ) {
392 + if ( ( ! is_int( $contact_id ) && ! is_string( $contact_id ) ) || ! ctype_digit( (string) $contact_id ) ) {
393 + return '';
394 + }
395 + $contact_id = (int) $contact_id;
396 + $email = get_post_meta( $contact_id, '_email_address', true );
397 + if ( ! $contact_id || 'contact' !== get_post_type( $contact_id ) || ! $this->contact_unsubscribe_recipients( $email ) ) {
398 + return '';
399 + }
400 + return $this->contact_unsubscribe_token_url( 'v2|' . $contact_id, $email );
401 + }
291 402
292 - $contact_id = $explode_ph_unsubscribe[0];
293 - if ( FALSE === get_post_status( $contact_id ) )
294 - {
295 - die("Invalid token passed 3");
403 + /**
404 + * Sign the purpose/version, payload and current email with the site's secret.
405 + */
406 + private function contact_unsubscribe_token_url( $payload, $email ) {
407 + $signature = hash_hmac( 'sha256', 'propertyhive-unsubscribe|' . $payload . '|' . $email, wp_salt( 'auth' ) );
408 + return add_query_arg( 'ph_unsubscribe', rawurlencode( base64_encode( $payload . '|' . $signature ) ), site_url( '/' ) );
409 + }
410 +
411 + /**
412 + * Atomically limit mailbox-verification mail, including concurrent requests.
413 + */
414 + private function contact_unsubscribe_mail_slot( $contact_id ) {
415 + global $wpdb;
416 + $key = 'propertyhive_unsubscribe_cooldown_' . $contact_id;
417 + $now = time();
418 + $previous = get_option( $key, false );
419 + if ( false !== $previous ) {
420 + if ( ! is_numeric( $previous ) || (int) $previous > $now ) {
296 421 return false;
297 422 }
298 - $contact_email = get_post_meta( $contact_id, '_email_address', TRUE );
423 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Compare-and-delete the expired lock atomically: delete_option could remove a newer request's lock. Invalidate the option cache immediately below.
424 + $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, (string) $previous ) );
425 + wp_cache_delete( $key, 'options' );
426 + }
427 + // INSERT IGNORE must not overwrite another request's newly acquired lock (add_option can update duplicate rows).
428 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The unique option_name index is the cross-request lock; clear positive and negative option caches immediately after the atomic insert.
429 + $acquired = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)", $key, (string) ( $now + 5 * MINUTE_IN_SECONDS ), 'no' ) );
430 + wp_cache_delete( $key, 'options' );
431 + wp_cache_delete( 'notoptions', 'options' );
432 + return 1 === $acquired;
433 + }
299 434
300 - if (md5($contact_email) != $explode_ph_unsubscribe[1])
301 - {
302 - die("Invalid token passed 4");
303 - return false;
304 - }
435 + private function contact_unsubscribe_result( $message, $status = 200 ) {
436 + wp_die( esc_html( $message ), esc_html__( 'Unsubscribe', 'propertyhive' ), array( 'response' => (int) $status ) );
437 + }
305 438
306 - // TODO: Make sure not already unsubscribed
439 + /**
440 + * Signed links authorize unsubscribe; old links first require mailbox proof.
441 + */
442 + public function unsubscribe_contact() {
443 + if ( ! isset( $_GET['ph_unsubscribe'] ) ) {
444 + return;
445 + }
446 + $invalid = __( 'This unsubscribe link is invalid or has expired.', 'propertyhive' );
447 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.NonceVerification.Recommended -- Bound the raw token's size before decoding; no value is used until signature/mailbox verification below.
448 + if ( ! is_string( $_GET['ph_unsubscribe'] ) || strlen( $_GET['ph_unsubscribe'] ) > 512 ) {
449 + $this->contact_unsubscribe_result( $invalid, 400 );
450 + return;
451 + }
452 + $encoded_token = sanitize_text_field( wp_unslash( $_GET['ph_unsubscribe'] ) );
453 + $decoded = base64_decode( $encoded_token, true );
454 + $parts = false !== $decoded ? explode( '|', $decoded ) : array();
455 + $legacy = count( $parts ) === 2;
456 + $version = $legacy ? 'legacy' : ( isset( $parts[0] ) ? $parts[0] : '' );
457 + $id_part = $legacy ? $parts[0] : ( isset( $parts[1] ) ? $parts[1] : '' );
458 + $contact_id = ctype_digit( $id_part ) ? (int) $id_part : 0;
459 + $email = $contact_id ? get_post_meta( $contact_id, '_email_address', true ) : '';
460 + if ( ! $contact_id || 'contact' !== get_post_type( $contact_id ) || ! $this->contact_unsubscribe_recipients( $email ) ) {
461 + $this->contact_unsubscribe_result( $invalid, 400 );
462 + return;
463 + }
307 464
308 - // We've got this far. We received a valid token and email address
309 - $forbidden_contact_methods = get_post_meta( $contact_id, '_forbidden_contact_methods', TRUE );
310 - if (!is_array($forbidden_contact_methods))
311 - {
312 - $forbidden_contact_methods = array();
465 + if ( $legacy ) {
466 + if ( ! hash_equals( md5( $email ), $parts[1] ) ) {
467 + $this->contact_unsubscribe_result( $invalid, 400 );
468 + return;
313 469 }
314 - $forbidden_contact_methods[] = 'email';
315 - update_post_meta( $contact_id, '_forbidden_contact_methods', array_unique($forbidden_contact_methods) );
470 + $nonce_action = 'propertyhive-unsubscribe-request-' . $contact_id;
471 + if ( isset( $_POST['propertyhive_unsubscribe_confirm'] ) ) {
472 + if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== $_SERVER['REQUEST_METHOD'] || ! is_string( $_POST['propertyhive_unsubscribe_confirm'] ) || '1' !== $_POST['propertyhive_unsubscribe_confirm'] || ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), $nonce_action ) ) {
473 + $this->contact_unsubscribe_result( $invalid, 400 );
474 + return;
475 + }
476 + if ( $this->contact_unsubscribe_mail_slot( $contact_id ) ) {
477 + // Reserve the cooldown before sending, including delivery failures.
478 + $nonce = wp_generate_password( 32, false, false );
479 + $expires = time() + HOUR_IN_SECONDS;
480 + $verify_key = 'propertyhive_unsubscribe_verify_' . $contact_id . '_' . hash( 'sha256', $nonce );
481 + set_transient( $verify_key, 1, HOUR_IN_SECONDS );
482 + $url = $this->contact_unsubscribe_token_url( 'v3|' . $contact_id . '|' . $expires . '|' . $nonce, $email );
483 + $sent = wp_mail(
484 + $this->contact_unsubscribe_recipients( $email ),
485 + __( 'Confirm your unsubscribe request', 'propertyhive' ),
486 + /* translators: %s: Mailbox verification URL. */
487 + sprintf( __( "To confirm your unsubscribe request, open this link within one hour:\n\n%s\n\nIf you did not request this, you can ignore this email.", 'propertyhive' ), $url )
488 + );
489 + if ( ! $sent ) {
490 + delete_transient( $verify_key );
491 + }
492 + }
493 + $this->contact_unsubscribe_result( __( 'Please check your inbox for a confirmation link. If you recently requested one, please allow a few minutes before trying again.', 'propertyhive' ) );
494 + return;
495 + }
496 + $legacy_url = add_query_arg( 'ph_unsubscribe', rawurlencode( $encoded_token ), site_url( '/' ) );
497 + $form = '<p>' . esc_html__( 'This older unsubscribe link requires email confirmation. Request a confirmation link to continue.', 'propertyhive' ) . '</p>';
498 + $form .= '<form method="post" action="' . esc_url( $legacy_url ) . '"><input type="hidden" name="propertyhive_unsubscribe_confirm" value="1"><input type="hidden" name="_wpnonce" value="' . esc_attr( wp_create_nonce( $nonce_action ) ) . '"><button type="submit">' . esc_html__( 'Send confirmation link', 'propertyhive' ) . '</button></form>';
499 + wp_die( wp_kses( $form, array( 'p' => array(), 'form' => array( 'method' => true, 'action' => true ), 'input' => array( 'type' => true, 'name' => true, 'value' => true ), 'button' => array( 'type' => true ) ) ), esc_html__( 'Unsubscribe', 'propertyhive' ), array( 'response' => 200 ) );
500 + return;
501 + }
316 502
317 - // Write note to applicant
318 - $comment = array(
319 - 'note_type' => 'unsubscribe'
320 - );
503 + $is_verification = 'v3' === $version && count( $parts ) === 5;
504 + if ( ! ( 'v2' === $version && count( $parts ) === 3 ) && ! $is_verification ) {
505 + $this->contact_unsubscribe_result( $invalid, 400 );
506 + return;
507 + }
508 + $signature = array_pop( $parts );
509 + $expected = hash_hmac( 'sha256', 'propertyhive-unsubscribe|' . implode( '|', $parts ) . '|' . $email, wp_salt( 'auth' ) );
510 + if ( ! hash_equals( $expected, $signature ) || ! isset( $_SERVER['REQUEST_METHOD'] ) || 'GET' !== $_SERVER['REQUEST_METHOD'] ) {
511 + $this->contact_unsubscribe_result( $invalid, 400 );
512 + return;
513 + }
514 + if ( $is_verification ) {
515 + $verify_key = 'propertyhive_unsubscribe_verify_' . $contact_id . '_' . hash( 'sha256', $parts[3] );
516 + if ( ! ctype_digit( $parts[2] ) || (int) $parts[2] <= time() || ! get_transient( $verify_key ) || ! delete_transient( $verify_key ) ) {
517 + $this->contact_unsubscribe_result( $invalid, 400 );
518 + return;
519 + }
520 + }
321 521
322 - $data = array(
323 - 'comment_post_ID' => $contact_id,
324 - 'comment_author' => 'Property Hive',
522 + $methods = get_post_meta( $contact_id, '_forbidden_contact_methods', true );
523 + $methods = is_array( $methods ) ? $methods : array();
524 + if ( ! in_array( 'email', $methods, true ) ) {
525 + $methods[] = 'email';
526 + update_post_meta( $contact_id, '_forbidden_contact_methods', wp_slash( array_unique( $methods ) ) );
527 + wp_insert_comment( array(
528 + 'comment_post_ID' => $contact_id,
529 + 'comment_author' => 'Property Hive',
325 530 'comment_author_email' => 'propertyhive@noreply.com',
326 - 'comment_author_url' => '',
327 - 'comment_date' => date("Y-m-d H:i:s"),
328 - 'comment_content' => serialize($comment),
329 - 'comment_approved' => 1,
330 - 'comment_type' => 'propertyhive_note',
331 - );
332 - wp_insert_comment( $data );
333 -
334 - die("You have been unsubscribed successfully. Please allow up to 24 hours for this to take effect.");
531 + 'comment_author_url' => '',
532 + 'comment_date' => gmdate( 'Y-m-d H:i:s' ),
533 + 'comment_content' => serialize( array( 'note_type' => 'unsubscribe' ) ),
534 + 'comment_approved' => 1,
535 + 'comment_type' => 'propertyhive_note',
536 + ) );
335 537 }
538 + $this->contact_unsubscribe_result( __( 'You have been unsubscribed successfully. Please allow up to 24 hours for this to take effect.', 'propertyhive' ) );
336 539 }
337 540
338 541 /**
339 542 * Init PropertyHive when WordPress Initialises.
@@ -339,8 +542,9 @@
339 542 * Init PropertyHive when WordPress Initialises.
340 543 */
341 544 public function init() {
342 545 // Before init action
546 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Existing public Property Hive extension hook before_propertyhive_init; changing the established name would detach installed callbacks.
343 547 do_action( 'before_propertyhive_init' );
344 548
345 549 // Set up localisation
346 550 $this->load_plugin_textdomain();
@@ -381,8 +585,9 @@
381 585 *
382 586 * Note: the first-loaded translation file overrides any following ones if the same translation is present
383 587 */
384 588 public function load_plugin_textdomain() {
589 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook plugin_locale; renaming it would break the core hook contract.
385 590 $locale = apply_filters( 'plugin_locale', get_locale(), 'propertyhive' );
386 591
387 592 // Admin Locale
388 593 if ( is_admin() ) {
@@ -391,8 +596,9 @@
391 596 }
392 597
393 598 // Global + Frontend Locale
394 599 load_textdomain( 'propertyhive', WP_LANG_DIR . "/propertyhive/propertyhive-$locale.mo" );
600 + // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- Preserve bundled i18n/languages translations and the plugin_locale override on supported WordPress versions; WordPress.org language packs alone do not cover this legacy custom path.
395 601 load_plugin_textdomain( 'propertyhive', false, plugin_basename( dirname( __FILE__ ) ) . "/i18n/languages" );
396 602 }
397 603
398 604 /**
@@ -399,27 +605,36 @@
399 605 * Ensure theme and server variable compatibility and setup image sizes..
400 606 */
401 607 public function setup_environment() {
402 608
403 - // IIS
404 - if ( ! isset($_SERVER['REQUEST_URI'] ) ) {
405 - $_SERVER['REQUEST_URI'] = substr( $_SERVER['PHP_SELF'], 1 );
406 - if ( isset( $_SERVER['QUERY_STRING'] ) ) {
407 - $_SERVER['REQUEST_URI'].='?'.$_SERVER['QUERY_STRING'];
609 + // IIS fallback must preserve encoded URLs and query syntax for WordPress routing.
610 + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
611 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This is server-to-server URI compatibility state, not output; validate string shape and remove CR/LF while preserving URL encodings and query delimiters.
612 + $php_self = isset( $_SERVER['PHP_SELF'] ) && is_string( $_SERVER['PHP_SELF'] ) ? str_replace( array( "\r", "\n" ), '', wp_unslash( $_SERVER['PHP_SELF'] ) ) : '';
613 + $_SERVER['REQUEST_URI'] = substr( $php_self, 1 );
614 + if ( isset( $_SERVER['QUERY_STRING'] ) && is_string( $_SERVER['QUERY_STRING'] ) ) {
615 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Preserve the existing query string exactly apart from CR/LF; URL output escaping belongs at its eventual output boundary.
616 + $_SERVER['REQUEST_URI'] .= '?' . str_replace( array( "\r", "\n" ), '', wp_unslash( $_SERVER['QUERY_STRING'] ) );
408 617 }
409 618 }
410 -
411 - // NGINX Proxy
412 - if ( ! isset( $_SERVER['REMOTE_ADDR'] ) && isset( $_SERVER['HTTP_REMOTE_ADDR'] ) ) {
413 - $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_REMOTE_ADDR'];
619 +
620 + // Legacy NGINX proxy compatibility; only copy syntactically valid IP addresses.
621 + if ( ! isset( $_SERVER['REMOTE_ADDR'] ) && isset( $_SERVER['HTTP_REMOTE_ADDR'] ) && is_string( $_SERVER['HTTP_REMOTE_ADDR'] ) ) {
622 + $remote_address = sanitize_text_field( wp_unslash( $_SERVER['HTTP_REMOTE_ADDR'] ) );
623 + if ( filter_var( $remote_address, FILTER_VALIDATE_IP ) ) {
624 + $_SERVER['REMOTE_ADDR'] = $remote_address;
625 + }
414 626 }
415 -
416 - if ( ! isset( $_SERVER['HTTPS'] ) && ! empty( $_SERVER['HTTP_HTTPS'] ) ) {
417 - $_SERVER['HTTPS'] = $_SERVER['HTTP_HTTPS'];
627 +
628 + if ( ! isset( $_SERVER['HTTPS'] ) && isset( $_SERVER['HTTP_HTTPS'] ) && is_string( $_SERVER['HTTP_HTTPS'] ) ) {
629 + $https = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HTTPS'] ) );
630 + if ( '' !== $https && '0' !== $https ) {
631 + $_SERVER['HTTPS'] = $https;
632 + }
418 633 }
419 -
420 - // Support for hosts which don't use HTTPS, and use HTTP_X_FORWARDED_PROTO
421 - if ( ! isset( $_SERVER['HTTPS'] ) && ! empty( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https' ) {
634 +
635 + // Support hosts which use HTTP_X_FORWARDED_PROTO instead of HTTPS.
636 + if ( ! isset( $_SERVER['HTTPS'] ) && isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
422 637 $_SERVER['HTTPS'] = '1';
423 638 }
424 639 }
425 640
@@ -448,8 +663,9 @@
448 663 *
449 664 * @return string
450 665 */
451 666 public function template_path() {
667 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy public template path filter; themes and extensions can customize the plugin template directory through this exact hook.
452 668 return apply_filters( 'PH_TEMPLATE_PATH', 'propertyhive/' );
453 669 }
454 670
455 671 /**
@@ -469,9 +685,9 @@
469 685 * @return string
470 686 */
471 687 public function api_request_url( $request, $ssl = null ) {
472 688 if ( is_null( $ssl ) ) {
473 - $scheme = parse_url( get_option( 'home' ), PHP_URL_SCHEME );
689 + $scheme = wp_parse_url( get_option( 'home' ), PHP_URL_SCHEME );
474 690 } elseif ( $ssl ) {
475 691 $scheme = 'https';
476 692 } else {
477 693 $scheme = 'http';
@@ -493,10 +709,11 @@
493 709 *
494 710 * @since 1.0.0
495 711 * @return PropertyHive
496 712 */
713 +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper PH; the established callable name is part of the plugin/extension API and must remain stable.
497 714 function PH() {
498 715 return PropertyHive::instance();
499 716 }
500 717
501 718 // Global for backwards compatibility.
502 -$GLOBALS['propertyhive'] = PH();
719 +$GLOBALS['propertyhive'] = PH();