- Fix production debug exposure in Zoho admin interface (WP_DEBUG conditional) - Implement secure credential storage with AES-256-CBC encryption - Add file upload size limits (5MB profiles, 2MB logos) with enhanced validation - Fix privilege escalation via PHP Reflection bypass with public method alternative - Add comprehensive input validation and security headers - Update plugin version to 1.0.7 with security hardening Security improvements: ✅ Debug information exposure eliminated in production ✅ API credentials now encrypted in database storage ✅ File upload security enhanced with size/type validation ✅ AJAX endpoints secured with proper capability checks ✅ SQL injection protection verified via parameterized queries ✅ CSRF protection maintained with nonce verification 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
		
			
				
	
	
		
			377 lines
		
	
	
		
			No EOL
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			377 lines
		
	
	
		
			No EOL
		
	
	
		
			14 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| if (!defined('ABSPATH')) {
 | |
|     exit;
 | |
| }
 | |
| 
 | |
| class HVAC_Geocoding_Service {
 | |
|     
 | |
|     private static $instance = null;
 | |
|     private static $api_key;
 | |
|     private static $rate_limit = 50; // requests per minute
 | |
|     private static $cache_duration = DAY_IN_SECONDS;
 | |
|     
 | |
|     public static function get_instance() {
 | |
|         if (null === self::$instance) {
 | |
|             self::$instance = new self();
 | |
|         }
 | |
|         return self::$instance;
 | |
|     }
 | |
|     
 | |
|     private function __construct() {
 | |
|         // Load secure storage class
 | |
|         if (!class_exists('HVAC_Secure_Storage')) {
 | |
|             require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-secure-storage.php';
 | |
|         }
 | |
|         
 | |
|         self::$api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key');
 | |
|         
 | |
|         // Hook into profile address updates
 | |
|         add_action('updated_post_meta', [$this, 'maybe_geocode'], 10, 4);
 | |
|         add_action('hvac_profile_address_updated', [$this, 'schedule_geocoding']);
 | |
|         
 | |
|         // Register geocoding action
 | |
|         add_action('hvac_geocode_address', [$this, 'geocode_address']);
 | |
|     }
 | |
|     
 | |
|     public function maybe_geocode($meta_id, $post_id, $meta_key, $meta_value) {
 | |
|         if (get_post_type($post_id) !== 'trainer_profile') {
 | |
|             return;
 | |
|         }
 | |
|         
 | |
|         $address_fields = ['trainer_city', 'trainer_state', 'trainer_country'];
 | |
|         if (!in_array($meta_key, $address_fields)) {
 | |
|             return;
 | |
|         }
 | |
|         
 | |
|         // Debounce rapid updates
 | |
|         $last_geocode = get_post_meta($post_id, '_last_geocode_attempt', true);
 | |
|         if ($last_geocode && (time() - $last_geocode) < 30) {
 | |
|             return;
 | |
|         }
 | |
|         
 | |
|         // Schedule geocoding with delay to allow all address fields to be saved
 | |
|         $this->schedule_geocoding($post_id);
 | |
|     }
 | |
|     
 | |
|     public function schedule_geocoding($post_id) {
 | |
|         wp_schedule_single_event(time() + 5, 'hvac_geocode_address', [$post_id]);
 | |
|     }
 | |
|     
 | |
|     public function geocode_address($post_id) {
 | |
|         // Check rate limiting
 | |
|         if (!$this->check_rate_limit()) {
 | |
|             // Reschedule for later
 | |
|             wp_schedule_single_event(time() + 60, 'hvac_geocode_address', [$post_id]);
 | |
|             return;
 | |
|         }
 | |
|         
 | |
|         $address = $this->build_address($post_id);
 | |
|         if (empty($address)) {
 | |
|             return;
 | |
|         }
 | |
|         
 | |
|         update_post_meta($post_id, '_last_geocode_attempt', time());
 | |
|         
 | |
|         // Check cache first
 | |
|         $cache_key = 'geocode_' . md5($address);
 | |
|         $cached = get_transient($cache_key);
 | |
|         
 | |
|         if ($cached !== false) {
 | |
|             $this->update_coordinates($post_id, $cached);
 | |
|             return;
 | |
|         }
 | |
|         
 | |
|         // Make API request
 | |
|         $result = $this->make_geocoding_request($address);
 | |
|         
 | |
|         if ($result && isset($result['lat'], $result['lng'])) {
 | |
|             // Cache successful result
 | |
|             set_transient($cache_key, $result, self::$cache_duration);
 | |
|             $this->update_coordinates($post_id, $result);
 | |
|             
 | |
|             update_post_meta($post_id, '_geocoding_status', 'success');
 | |
|             update_post_meta($post_id, '_last_geocoded', time());
 | |
|         } else {
 | |
|             // Handle failure
 | |
|             $this->handle_geocoding_failure($post_id, $result);
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private function build_address($post_id) {
 | |
|         $city = get_post_meta($post_id, 'trainer_city', true);
 | |
|         $state = get_post_meta($post_id, 'trainer_state', true);
 | |
|         $country = get_post_meta($post_id, 'trainer_country', true);
 | |
|         
 | |
|         $address_parts = array_filter([$city, $state, $country]);
 | |
|         return implode(', ', $address_parts);
 | |
|     }
 | |
|     
 | |
|     private function check_rate_limit() {
 | |
|         $rate_key = 'hvac_geocoding_rate_' . gmdate('Y-m-d-H-i');
 | |
|         $current_count = get_transient($rate_key) ?: 0;
 | |
|         
 | |
|         if ($current_count >= self::$rate_limit) {
 | |
|             return false;
 | |
|         }
 | |
|         
 | |
|         set_transient($rate_key, $current_count + 1, 60);
 | |
|         return true;
 | |
|     }
 | |
|     
 | |
|     private function make_geocoding_request($address) {
 | |
|         if (empty(self::$api_key)) {
 | |
|             return ['error' => 'No API key configured'];
 | |
|         }
 | |
|         
 | |
|         $url = 'https://maps.googleapis.com/maps/api/geocode/json';
 | |
|         $params = [
 | |
|             'address' => $address,
 | |
|             'key' => self::$api_key,
 | |
|             'components' => 'country:US|country:CA' // Restrict to North America
 | |
|         ];
 | |
|         
 | |
|         $response = wp_remote_get($url . '?' . http_build_query($params), [
 | |
|             'timeout' => 10,
 | |
|             'user-agent' => 'HVAC Trainer Directory/1.0'
 | |
|         ]);
 | |
|         
 | |
|         if (is_wp_error($response)) {
 | |
|             return ['error' => $response->get_error_message()];
 | |
|         }
 | |
|         
 | |
|         $body = wp_remote_retrieve_body($response);
 | |
|         $data = json_decode($body, true);
 | |
|         
 | |
|         if (!$data || $data['status'] !== 'OK' || empty($data['results'])) {
 | |
|             return ['error' => $data['status'] ?? 'Unknown error'];
 | |
|         }
 | |
|         
 | |
|         $location = $data['results'][0]['geometry']['location'];
 | |
|         return [
 | |
|             'lat' => $location['lat'],
 | |
|             'lng' => $location['lng'],
 | |
|             'formatted_address' => $data['results'][0]['formatted_address'],
 | |
|             'confidence' => $this->calculate_confidence($data['results'][0])
 | |
|         ];
 | |
|     }
 | |
|     
 | |
|     private function update_coordinates($post_id, $result) {
 | |
|         update_post_meta($post_id, 'latitude', $result['lat']);
 | |
|         update_post_meta($post_id, 'longitude', $result['lng']);
 | |
|         
 | |
|         if (isset($result['formatted_address'])) {
 | |
|             update_post_meta($post_id, 'formatted_address', $result['formatted_address']);
 | |
|         }
 | |
|         
 | |
|         if (isset($result['confidence'])) {
 | |
|             update_post_meta($post_id, 'geocoding_confidence', $result['confidence']);
 | |
|         }
 | |
|         
 | |
|         if (isset($result['source'])) {
 | |
|             update_post_meta($post_id, 'geocoding_source', $result['source']);
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private function handle_geocoding_failure($post_id, $error_result) {
 | |
|         $error_message = $error_result['error'] ?? 'Unknown error';
 | |
|         
 | |
|         update_post_meta($post_id, '_geocoding_status', 'failed');
 | |
|         update_post_meta($post_id, '_geocoding_error', $error_message);
 | |
|         
 | |
|         // Implement retry logic based on error type
 | |
|         switch ($error_message) {
 | |
|             case 'OVER_QUERY_LIMIT':
 | |
|                 // Retry in 1 hour
 | |
|                 wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_address', [$post_id]);
 | |
|                 break;
 | |
|                 
 | |
|             case 'ZERO_RESULTS':
 | |
|                 // Try fallback geocoding service
 | |
|                 $this->try_fallback_geocoding($post_id);
 | |
|                 break;
 | |
|                 
 | |
|             case 'REQUEST_DENIED':
 | |
|                 // Log API key issue
 | |
|                 error_log("HVAC Geocoding: API key issue - {$error_message}");
 | |
|                 break;
 | |
|                 
 | |
|             default:
 | |
|                 // Retry in 5 minutes for transient errors
 | |
|                 wp_schedule_single_event(time() + 300, 'hvac_geocode_address', [$post_id]);
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private function try_fallback_geocoding($post_id) {
 | |
|         // Implement OpenStreetMap Nominatim as fallback
 | |
|         $address = $this->build_address($post_id);
 | |
|         
 | |
|         $url = 'https://nominatim.openstreetmap.org/search';
 | |
|         $params = [
 | |
|             'q' => $address,
 | |
|             'format' => 'json',
 | |
|             'limit' => 1,
 | |
|             'countrycodes' => 'us,ca'
 | |
|         ];
 | |
|         
 | |
|         $response = wp_remote_get($url . '?' . http_build_query($params), [
 | |
|             'timeout' => 10,
 | |
|             'user-agent' => 'HVAC Trainer Directory/1.0'
 | |
|         ]);
 | |
|         
 | |
|         if (!is_wp_error($response)) {
 | |
|             $body = wp_remote_retrieve_body($response);
 | |
|             $data = json_decode($body, true);
 | |
|             
 | |
|             if (!empty($data) && isset($data[0]['lat'], $data[0]['lon'])) {
 | |
|                 $result = [
 | |
|                     'lat' => floatval($data[0]['lat']),
 | |
|                     'lng' => floatval($data[0]['lon']),
 | |
|                     'formatted_address' => $data[0]['display_name'],
 | |
|                     'confidence' => 0.7, // Lower confidence for fallback
 | |
|                     'source' => 'nominatim'
 | |
|                 ];
 | |
|                 
 | |
|                 $this->update_coordinates($post_id, $result);
 | |
|                 update_post_meta($post_id, '_geocoding_status', 'success_fallback');
 | |
|                 return;
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         // Final fallback failed
 | |
|         update_post_meta($post_id, '_geocoding_status', 'failed_all');
 | |
|     }
 | |
|     
 | |
|     private function calculate_confidence($result) {
 | |
|         $types = $result['types'] ?? [];
 | |
|         
 | |
|         // Higher confidence for more specific location types
 | |
|         if (in_array('street_address', $types)) return 1.0;
 | |
|         if (in_array('premise', $types)) return 0.9;
 | |
|         if (in_array('subpremise', $types)) return 0.85;
 | |
|         if (in_array('locality', $types)) return 0.8;
 | |
|         if (in_array('administrative_area_level_3', $types)) return 0.7;
 | |
|         if (in_array('administrative_area_level_2', $types)) return 0.6;
 | |
|         if (in_array('administrative_area_level_1', $types)) return 0.5;
 | |
|         
 | |
|         return 0.4; // Low confidence for country-level matches
 | |
|     }
 | |
|     
 | |
|     // Public methods for manual geocoding
 | |
|     public function geocode_profile($profile_id) {
 | |
|         return $this->geocode_address($profile_id);
 | |
|     }
 | |
|     
 | |
|     public function get_coordinates($profile_id) {
 | |
|         $lat = get_post_meta($profile_id, 'latitude', true);
 | |
|         $lng = get_post_meta($profile_id, 'longitude', true);
 | |
|         
 | |
|         if ($lat && $lng) {
 | |
|             return [
 | |
|                 'latitude' => floatval($lat),
 | |
|                 'longitude' => floatval($lng),
 | |
|                 'formatted_address' => get_post_meta($profile_id, 'formatted_address', true),
 | |
|                 'confidence' => get_post_meta($profile_id, 'geocoding_confidence', true),
 | |
|                 'source' => get_post_meta($profile_id, 'geocoding_source', true) ?: 'google',
 | |
|                 'last_geocoded' => get_post_meta($profile_id, '_last_geocoded', true)
 | |
|             ];
 | |
|         }
 | |
|         
 | |
|         return null;
 | |
|     }
 | |
|     
 | |
|     public function get_geocoding_status($profile_id) {
 | |
|         return [
 | |
|             'status' => get_post_meta($profile_id, '_geocoding_status', true),
 | |
|             'error' => get_post_meta($profile_id, '_geocoding_error', true),
 | |
|             'last_attempt' => get_post_meta($profile_id, '_last_geocode_attempt', true),
 | |
|             'last_success' => get_post_meta($profile_id, '_last_geocoded', true)
 | |
|         ];
 | |
|     }
 | |
|     
 | |
|     public function clear_coordinates($profile_id) {
 | |
|         delete_post_meta($profile_id, 'latitude');
 | |
|         delete_post_meta($profile_id, 'longitude');
 | |
|         delete_post_meta($profile_id, 'formatted_address');
 | |
|         delete_post_meta($profile_id, 'geocoding_confidence');
 | |
|         delete_post_meta($profile_id, 'geocoding_source');
 | |
|         delete_post_meta($profile_id, '_geocoding_status');
 | |
|         delete_post_meta($profile_id, '_geocoding_error');
 | |
|         delete_post_meta($profile_id, '_last_geocoded');
 | |
|     }
 | |
|     
 | |
|     // Proximity search functionality
 | |
|     public function find_nearby_profiles($latitude, $longitude, $radius_km = 50, $limit = 20) {
 | |
|         global $wpdb;
 | |
|         
 | |
|         $query = $wpdb->prepare("
 | |
|             SELECT p.ID, p.post_title,
 | |
|                    lat_meta.meta_value as latitude,
 | |
|                    lng_meta.meta_value as longitude,
 | |
|                    (
 | |
|                        6371 * acos(
 | |
|                            cos(radians(%f)) * 
 | |
|                            cos(radians(CAST(lat_meta.meta_value AS DECIMAL(10,8)))) * 
 | |
|                            cos(radians(CAST(lng_meta.meta_value AS DECIMAL(11,8))) - radians(%f)) + 
 | |
|                            sin(radians(%f)) * 
 | |
|                            sin(radians(CAST(lat_meta.meta_value AS DECIMAL(10,8))))
 | |
|                        )
 | |
|                    ) AS distance
 | |
|             FROM {$wpdb->posts} p
 | |
|             LEFT JOIN {$wpdb->postmeta} lat_meta ON p.ID = lat_meta.post_id AND lat_meta.meta_key = 'latitude'
 | |
|             LEFT JOIN {$wpdb->postmeta} lng_meta ON p.ID = lng_meta.post_id AND lng_meta.meta_key = 'longitude'
 | |
|             LEFT JOIN {$wpdb->postmeta} public_meta ON p.ID = public_meta.post_id AND public_meta.meta_key = 'is_public_profile'
 | |
|             WHERE p.post_type = 'trainer_profile'
 | |
|             AND p.post_status = 'publish'
 | |
|             AND lat_meta.meta_value IS NOT NULL
 | |
|             AND lng_meta.meta_value IS NOT NULL
 | |
|             AND public_meta.meta_value = '1'
 | |
|             HAVING distance < %d
 | |
|             ORDER BY distance ASC
 | |
|             LIMIT %d
 | |
|         ", $latitude, $longitude, $latitude, $radius_km, $limit);
 | |
|         
 | |
|         return $wpdb->get_results($query);
 | |
|     }
 | |
|     
 | |
|     // Bulk geocoding for migration
 | |
|     public function bulk_geocode_profiles($limit = 10) {
 | |
|         $profiles = get_posts([
 | |
|             'post_type' => 'trainer_profile',
 | |
|             'posts_per_page' => $limit,
 | |
|             'meta_query' => [
 | |
|                 'relation' => 'AND',
 | |
|                 [
 | |
|                     'relation' => 'OR',
 | |
|                     [
 | |
|                         'key' => 'trainer_city',
 | |
|                         'compare' => 'EXISTS'
 | |
|                     ],
 | |
|                     [
 | |
|                         'key' => 'trainer_state', 
 | |
|                         'compare' => 'EXISTS'
 | |
|                     ]
 | |
|                 ],
 | |
|                 [
 | |
|                     'key' => 'latitude',
 | |
|                     'compare' => 'NOT EXISTS'
 | |
|                 ]
 | |
|             ]
 | |
|         ]);
 | |
|         
 | |
|         $processed = 0;
 | |
|         foreach ($profiles as $profile) {
 | |
|             if ($this->check_rate_limit()) {
 | |
|                 $this->geocode_address($profile->ID);
 | |
|                 $processed++;
 | |
|             } else {
 | |
|                 break;
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         return $processed;
 | |
|     }
 | |
| }
 | |
| 
 | |
| // Initialize the geocoding service
 | |
| HVAC_Geocoding_Service::get_instance(); |