PluginProbe
Property Hive / 2.3.0
Property Hive v2.3.0
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 +297 -81 1.4.622.3.0 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.62
5 + * Description: Property Hive has everything you need to build estate agency websites
6 + * Version: 2.3.0
7 7 * Author: PropertyHive
8 8 * Author URI: https://wp-property-hive.com
9 - * Requires at least: 3.8
10 - * Tested up to: 5.4.2
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.62
31 + * @version 2.3.0
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.62';
38 + public $version = '2.3.0';
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' ) || (isset($_GET['action']) && $_GET['action'] == 'elementor') ) {
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,13 +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
228 316 include_once( 'includes/class-ph-elementor.php' ); // Elementor
317 + include_once( 'includes/class-ph-salient.php' ); // Salient / WPBakery
229 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
230 329
231 330 $this->query = new PH_Query();
232 331 $this->email = new PH_Emails();
233 332 $this->license = new PH_Licenses();
@@ -270,71 +369,174 @@
270 369 include_once( 'includes/widgets/class-ph-widget-properties.php' );*/
271 370 }
272 371
273 372 /**
274 - * 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.
275 374 */
276 - public function unsubscribe_contact() {
277 - if ( isset($_GET['ph_unsubscribe']) && !empty($_GET['ph_unsubscribe']) )
278 - {
279 - $ph_unsubscribe = sanitize_text_field(base64_decode($_GET['ph_unsubscribe']));
280 - if ( $ph_unsubscribe === FALSE )
281 - {
282 - die("Invalid token passed 1");
283 - 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();
284 383 }
384 + }
385 + return array_values( array_unique( $recipients ) );
386 + }
285 387
286 - $explode_ph_unsubscribe = explode("|", $ph_unsubscribe);
287 - if ( count($explode_ph_unsubscribe) != 2 )
288 - {
289 - die("Invalid token passed 2");
290 - return false;
291 - }
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 + }
292 402
293 - $contact_id = $explode_ph_unsubscribe[0];
294 - if ( FALSE === get_post_status( $contact_id ) )
295 - {
296 - 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 ) {
297 421 return false;
298 422 }
299 - $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 + }
300 434
301 - if (md5($contact_email) != $explode_ph_unsubscribe[1])
302 - {
303 - die("Invalid token passed 4");
304 - return false;
305 - }
435 + private function contact_unsubscribe_result( $message, $status = 200 ) {
436 + wp_die( esc_html( $message ), esc_html__( 'Unsubscribe', 'propertyhive' ), array( 'response' => (int) $status ) );
437 + }
306 438
307 - // 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 + }
308 464
309 - // We've got this far. We received a valid token and email address
310 - $forbidden_contact_methods = get_post_meta( $contact_id, '_forbidden_contact_methods', TRUE );
311 - if (!is_array($forbidden_contact_methods))
312 - {
313 - $forbidden_contact_methods = array();
465 + if ( $legacy ) {
466 + if ( ! hash_equals( md5( $email ), $parts[1] ) ) {
467 + $this->contact_unsubscribe_result( $invalid, 400 );
468 + return;
314 469 }
315 - $forbidden_contact_methods[] = 'email';
316 - 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 + }
317 502
318 - // Write note to applicant
319 - $comment = array(
320 - 'note_type' => 'unsubscribe'
321 - );
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 + }
322 521
323 - $data = array(
324 - 'comment_post_ID' => $contact_id,
325 - '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',
326 530 'comment_author_email' => 'propertyhive@noreply.com',
327 - 'comment_author_url' => '',
328 - 'comment_date' => date("Y-m-d H:i:s"),
329 - 'comment_content' => serialize($comment),
330 - 'comment_approved' => 1,
331 - 'comment_type' => 'propertyhive_note',
332 - );
333 - wp_insert_comment( $data );
334 -
335 - 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 + ) );
336 537 }
538 + $this->contact_unsubscribe_result( __( 'You have been unsubscribed successfully. Please allow up to 24 hours for this to take effect.', 'propertyhive' ) );
337 539 }
338 540
339 541 /**
340 542 * Init PropertyHive when WordPress Initialises.
@@ -340,8 +542,9 @@
340 542 * Init PropertyHive when WordPress Initialises.
341 543 */
342 544 public function init() {
343 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.
344 547 do_action( 'before_propertyhive_init' );
345 548
346 549 // Set up localisation
347 550 $this->load_plugin_textdomain();
@@ -382,8 +585,9 @@
382 585 *
383 586 * Note: the first-loaded translation file overrides any following ones if the same translation is present
384 587 */
385 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.
386 590 $locale = apply_filters( 'plugin_locale', get_locale(), 'propertyhive' );
387 591
388 592 // Admin Locale
389 593 if ( is_admin() ) {
@@ -392,8 +596,9 @@
392 596 }
393 597
394 598 // Global + Frontend Locale
395 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.
396 601 load_plugin_textdomain( 'propertyhive', false, plugin_basename( dirname( __FILE__ ) ) . "/i18n/languages" );
397 602 }
398 603
399 604 /**
@@ -400,27 +605,36 @@
400 605 * Ensure theme and server variable compatibility and setup image sizes..
401 606 */
402 607 public function setup_environment() {
403 608
404 - // IIS
405 - if ( ! isset($_SERVER['REQUEST_URI'] ) ) {
406 - $_SERVER['REQUEST_URI'] = substr( $_SERVER['PHP_SELF'], 1 );
407 - if ( isset( $_SERVER['QUERY_STRING'] ) ) {
408 - $_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'] ) );
409 617 }
410 618 }
411 -
412 - // NGINX Proxy
413 - if ( ! isset( $_SERVER['REMOTE_ADDR'] ) && isset( $_SERVER['HTTP_REMOTE_ADDR'] ) ) {
414 - $_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 + }
415 626 }
416 -
417 - if ( ! isset( $_SERVER['HTTPS'] ) && ! empty( $_SERVER['HTTP_HTTPS'] ) ) {
418 - $_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 + }
419 633 }
420 -
421 - // Support for hosts which don't use HTTPS, and use HTTP_X_FORWARDED_PROTO
422 - 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'] ) {
423 637 $_SERVER['HTTPS'] = '1';
424 638 }
425 639 }
426 640
@@ -449,8 +663,9 @@
449 663 *
450 664 * @return string
451 665 */
452 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.
453 668 return apply_filters( 'PH_TEMPLATE_PATH', 'propertyhive/' );
454 669 }
455 670
456 671 /**
@@ -470,9 +685,9 @@
470 685 * @return string
471 686 */
472 687 public function api_request_url( $request, $ssl = null ) {
473 688 if ( is_null( $ssl ) ) {
474 - $scheme = parse_url( get_option( 'home' ), PHP_URL_SCHEME );
689 + $scheme = wp_parse_url( get_option( 'home' ), PHP_URL_SCHEME );
475 690 } elseif ( $ssl ) {
476 691 $scheme = 'https';
477 692 } else {
478 693 $scheme = 'http';
@@ -494,10 +709,11 @@
494 709 *
495 710 * @since 1.0.0
496 711 * @return PropertyHive
497 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.
498 714 function PH() {
499 715 return PropertyHive::instance();
500 716 }
501 717
502 718 // Global for backwards compatibility.
503 -$GLOBALS['propertyhive'] = PH();
719 +$GLOBALS['propertyhive'] = PH();