client_id = HVAC_Secure_Storage::get_credential('hvac_zoho_client_id', ''); $this->client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', ''); $this->refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', ''); $this->redirect_uri = get_site_url() . '/oauth/callback'; // Fallback to config file if options are empty (backward compatibility) if (empty($this->client_id) || empty($this->client_secret)) { $config_file = plugin_dir_path(__FILE__) . 'zoho-config.php'; if (file_exists($config_file)) { require_once $config_file; $this->client_id = empty($this->client_id) && defined('ZOHO_CLIENT_ID') ? ZOHO_CLIENT_ID : $this->client_id; $this->client_secret = empty($this->client_secret) && defined('ZOHO_CLIENT_SECRET') ? ZOHO_CLIENT_SECRET : $this->client_secret; $this->refresh_token = empty($this->refresh_token) && defined('ZOHO_REFRESH_TOKEN') ? ZOHO_REFRESH_TOKEN : $this->refresh_token; $this->redirect_uri = defined('ZOHO_REDIRECT_URI') ? ZOHO_REDIRECT_URI : $this->redirect_uri; } } // Load stored access token from WordPress options $this->load_access_token(); } /** * Generate authorization URL for initial setup * * @return string Authorization URL with CSRF state parameter */ public function get_authorization_url() { // Generate secure state parameter for CSRF protection $state = $this->generate_oauth_state(); $params = array( 'scope' => 'ZohoCRM.settings.ALL,ZohoCRM.modules.ALL,ZohoCRM.users.ALL,ZohoCRM.org.ALL,ZohoCRM.bulk.READ', 'client_id' => $this->client_id, 'response_type' => 'code', 'access_type' => 'offline', 'redirect_uri' => $this->redirect_uri, 'prompt' => 'consent', 'state' => $state ); return 'https://accounts.zoho.com/oauth/v2/auth?' . http_build_query($params); } /** * Generate and store OAuth state parameter for CSRF protection * * @return string Generated state token */ public function generate_oauth_state() { $state = wp_generate_password(32, false); set_transient('hvac_zoho_oauth_state', $state, 600); // 10 minute expiry return $state; } /** * Validate OAuth state parameter * * @param string $state State parameter from callback * @return bool True if state is valid */ public function validate_oauth_state($state) { $stored_state = get_transient('hvac_zoho_oauth_state'); if (empty($stored_state) || empty($state)) { return false; } // Use timing-safe comparison $valid = hash_equals($stored_state, $state); // Delete the state after validation (one-time use) delete_transient('hvac_zoho_oauth_state'); return $valid; } /** * Exchange authorization code for tokens */ public function exchange_code_for_tokens($auth_code) { $url = 'https://accounts.zoho.com/oauth/v2/token'; $params = array( 'grant_type' => 'authorization_code', 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'redirect_uri' => $this->redirect_uri, 'code' => $auth_code ); $response = wp_remote_post($url, array( 'body' => $params, 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ) )); if (is_wp_error($response)) { $this->log_error('Failed to exchange code: ' . $response->get_error_message()); return false; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (isset($data['access_token']) && isset($data['refresh_token'])) { $this->access_token = $data['access_token']; $this->refresh_token = $data['refresh_token']; $this->token_expiry = time() + $data['expires_in']; // Save tokens $this->save_tokens(); return true; } $this->log_error('Invalid token response: ' . $body); return false; } /** * Get valid access token (refresh if needed) */ public function get_access_token() { // Check if token is expired or will expire soon (5 mins buffer) if (!$this->access_token || (time() + 300) >= $this->token_expiry) { $this->refresh_access_token(); } return $this->access_token; } /** * Refresh access token using refresh token */ private function refresh_access_token() { $url = 'https://accounts.zoho.com/oauth/v2/token'; $params = array( 'refresh_token' => $this->refresh_token, 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'grant_type' => 'refresh_token' ); $response = wp_remote_post($url, array( 'body' => $params, 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ) )); if (is_wp_error($response)) { $this->log_error('Failed to refresh token: ' . $response->get_error_message()); return false; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (isset($data['access_token'])) { $this->access_token = $data['access_token']; $this->token_expiry = time() + $data['expires_in']; $this->save_access_token(); return true; } $this->log_error('Failed to refresh token: ' . $body); return false; } /** * Make authenticated API request */ /** * Check if we are in staging mode * * @return bool True if in staging mode */ public static function is_staging_mode() { // 1. Allow forcing production mode via constant if (defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE) { return false; } // 2. Allow forcing staging mode via constant if (defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE) { return true; } // 3. Parse hostname from site URL for accurate comparison $site_url = get_site_url(); $host = wp_parse_url($site_url, PHP_URL_HOST); if (empty($host)) { return true; // Can't determine host, default to staging for safety } // 4. Production: upskillhvac.com or www.upskillhvac.com if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') { return false; } // 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.) return true; } /** * Get details about how the mode was determined (for debugging) * * @return array Debug information */ public static function get_debug_mode_info() { $site_url = get_site_url(); $host = wp_parse_url($site_url, PHP_URL_HOST); $info = array( 'site_url' => $site_url, 'parsed_host' => $host, 'is_staging' => self::is_staging_mode(), 'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE, 'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE, 'detection_logic' => array() ); // Replicate logic to show which rule matched if ($info['forced_production']) { $info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant'; } elseif ($info['forced_staging']) { $info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant'; } elseif (empty($host)) { $info['detection_logic'][] = 'STAGING: Could not parse hostname from URL'; } elseif ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') { $info['detection_logic'][] = 'PRODUCTION: Hostname matches upskillhvac.com'; } else { $info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com'; } return $info; } /** * Make authenticated API request */ public function make_api_request($endpoint, $method = 'GET', $data = null) { // Check if we're in staging mode $is_staging = self::is_staging_mode(); // In staging mode, only allow read operations, no writes if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) { $this->log_debug('STAGING MODE: Blocked ' . $method . ' request to ' . $endpoint); return array( 'data' => array( array( 'code' => 'STAGING_MODE', 'details' => array( 'message' => 'Staging mode active. Write operations are disabled.' ), 'message' => 'Blocked ' . $method . ' request to: ' . $endpoint, 'status' => 'skipped_staging' ) ) ); } // Debug logging of config status if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE) { $config_status = $this->get_configuration_status(); $this->log_debug('Configuration status: ' . json_encode($config_status)); if (!$config_status['client_id_exists']) { $this->log_error('Client ID is missing or empty'); } if (!$config_status['client_secret_exists']) { $this->log_error('Client Secret is missing or empty'); } if (!$config_status['refresh_token_exists']) { $this->log_error('Refresh Token is missing or empty'); } if ($config_status['token_expired']) { $this->log_debug('Access token is expired, will attempt to refresh'); } } $access_token = $this->get_access_token(); if (!$access_token) { $error_message = 'No valid access token available'; $this->log_error($error_message); return new WP_Error('no_token', $error_message); } // Update to v6 API (v2 is deprecated) $url = 'https://www.zohoapis.com/crm/v6' . $endpoint; // Log the request details $this->log_debug('Making ' . $method . ' request to: ' . $url); $args = array( 'method' => $method, 'headers' => array( 'Authorization' => 'Zoho-oauthtoken ' . $access_token, 'Content-Type' => 'application/json' ), 'timeout' => 30 // Increase timeout to 30 seconds for potentially slow responses ); if ($data && in_array($method, array('POST', 'PUT', 'PATCH'))) { $args['body'] = json_encode($data); $this->log_debug('Request payload: ' . json_encode($data)); } // Execute the request $this->log_debug('Executing request to Zoho API'); $response = wp_remote_request($url, $args); // Handle WordPress errors if (is_wp_error($response)) { $error_message = 'API request failed: ' . $response->get_error_message(); $error_data = $response->get_error_data(); $this->log_error($error_message); $this->log_debug('Error details: ' . json_encode($error_data)); return $response; } // Get response code and body $status_code = wp_remote_retrieve_response_code($response); $headers = wp_remote_retrieve_headers($response); $body = wp_remote_retrieve_body($response); $this->log_debug('Response code: ' . $status_code); // Log headers for debugging if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE) { $this->log_debug('Response headers: ' . json_encode($headers->getAll())); } // Handle empty responses if (empty($body)) { $error_message = 'Empty response received from Zoho API'; $this->log_error($error_message); return array( 'error' => $error_message, 'code' => $status_code, 'details' => 'No response body received' ); } // Parse the JSON response $data = json_decode($body, true); // Check for JSON parsing errors if ($data === null && json_last_error() !== JSON_ERROR_NONE) { $error_message = 'Invalid JSON response: ' . json_last_error_msg(); $this->log_error($error_message); $this->log_debug('Raw response: ' . $body); return array( 'error' => $error_message, 'code' => 'JSON_PARSE_ERROR', 'details' => 'Raw response: ' . substr($body, 0, 500), 'request_payload' => isset($args['body']) ? $args['body'] : 'No body' ); } // Log response for debugging if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE) { $this->log_debug('API Response: ' . $body); } // Check for API errors if ($status_code >= 400) { $error_message = isset($data['message']) ? $data['message'] : 'API error with status code ' . $status_code; $this->log_error($error_message); // Add HTTP error information to the response $data['http_status'] = $status_code; $data['error'] = $error_message; $data['request_payload'] = isset($args['body']) ? $args['body'] : 'No body'; // Extract more detailed error information if available if (isset($data['code'])) { $this->log_debug('Error code: ' . $data['code']); } if (isset($data['details'])) { $this->log_debug('Error details: ' . json_encode($data['details'])); } } return $data; } /** * Save tokens to WordPress options using secure storage */ private function save_tokens() { HVAC_Secure_Storage::store_credential('hvac_zoho_refresh_token', $this->refresh_token); $this->save_access_token(); } /** * Save access token using secure storage */ private function save_access_token() { HVAC_Secure_Storage::store_credential('hvac_zoho_access_token', $this->access_token); update_option('hvac_zoho_token_expiry', $this->token_expiry); } /** * Load access token from WordPress options using secure storage */ private function load_access_token() { $this->access_token = HVAC_Secure_Storage::get_credential('hvac_zoho_access_token', ''); $this->token_expiry = get_option('hvac_zoho_token_expiry', 0); // Load refresh token if not set if (!$this->refresh_token) { $this->refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', ''); } } /** * Log error messages */ private function log_error($message) { $this->last_error = $message; if (defined('ZOHO_LOG_FILE')) { error_log('[' . date('Y-m-d H:i:s') . '] ERROR: ' . $message . PHP_EOL, 3, ZOHO_LOG_FILE); } // Also log to WordPress debug log if available if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { error_log('[ZOHO CRM] ' . $message); } } /** * Log debug messages */ public function log_debug($message) { // Sanitize message to remove sensitive data $sanitized = $this->sanitize_log_message($message); if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE && defined('ZOHO_LOG_FILE')) { error_log('[' . date('Y-m-d H:i:s') . '] DEBUG: ' . $sanitized . PHP_EOL, 3, ZOHO_LOG_FILE); } // Also log to WordPress debug log if available if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE && defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { error_log('[ZOHO CRM DEBUG] ' . $sanitized); } } /** * Sanitize log messages to mask sensitive credentials * * @param string $message Log message * @return string Sanitized message */ private function sanitize_log_message($message) { // Mask client_id, client_secret, access_token, refresh_token patterns $patterns = array( '/(client[_-]?(id|secret)[\s:=]+)([a-zA-Z0-9._-]{10,})/i', '/(access[_-]?token[\s:=]+)([a-zA-Z0-9._-]{10,})/i', '/(refresh[_-]?token[\s:=]+)([a-zA-Z0-9._-]{10,})/i', '/(authorization[\s:]+)(Zoho-oauthtoken\s+[a-zA-Z0-9._-]+)/i', '/("(client_id|client_secret|access_token|refresh_token)"[\s:]+")[^"]+(")/i', ); $replacements = array( '$1***MASKED***', '$1***MASKED***', '$1***MASKED***', '$1Zoho-oauthtoken ***MASKED***', '$1***MASKED***$3', ); return preg_replace($patterns, $replacements, $message); } /** * Get the last error message * * @return string|null */ public function get_last_error() { return $this->last_error; } /** * Get client ID (for debugging only) * * @return string */ public function get_client_id() { return $this->client_id; } /** * Check if client secret exists (for debugging only) * * @return bool */ public function get_client_secret() { return !empty($this->client_secret); } /** * Check if refresh token exists (for debugging only) * * @return bool */ public function get_refresh_token() { return !empty($this->refresh_token); } /** * Get configuration status (for debugging) * * @return array */ public function get_configuration_status() { return array( 'client_id_exists' => !empty($this->client_id), 'client_secret_exists' => !empty($this->client_secret), 'refresh_token_exists' => !empty($this->refresh_token), 'access_token_exists' => !empty($this->access_token), 'token_expired' => $this->token_expiry < time(), 'config_loaded' => file_exists(plugin_dir_path(__FILE__) . 'zoho-config.php') ); } }