| @@ -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.49 | |
| 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.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.49 | |
| 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.49'; | |
| 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’ huh?', 'propertyhive' ), '1.0.0' ); | |
| 103 | + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin’ 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’ huh?', 'propertyhive' ), '1.0.0' ); | |
| 112 | + _doing_it_wrong( __FUNCTION__, esc_html__( 'Cheatin’ 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(); | |