upskill-event-manager/includes/class-hvac-geocoding-service.php
bengizmo 5ab2c58f68 feat: Implement comprehensive security fixes for production deployment
- 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>
2025-08-06 13:31:38 -03:00

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();