| | |
| | | protected array $apiEndpoints = []; // Valid endpoint paths for this service |
| | | protected string $apiVersion = ''; // API version string (e.g., 'v2', '2024-01-01') |
| | | |
| | | protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable |
| | | |
| | | /** |
| | | * OAuth Configuration |
| | |
| | | */ |
| | | protected array $credentials = []; // Service credentials (API keys, tokens, etc.) |
| | | protected ?int $userID = null; // User context for user-specific integrations |
| | | |
| | | private bool $token_refresh_attempted = false; // Circuit breaker for token refresh |
| | | |
| | | protected array $fields = []; // The fields to generate that become credentials |
| | | protected array $advanced = []; // The fields that are optional settings |
| | |
| | | { |
| | | $this->cacheName = $this->cacheName ?: $this->service_name; |
| | | $this->userID = $userID; |
| | | $this->cache = new CacheManager('integrations_' . $this->cacheName, $this->ttl); |
| | | $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl); |
| | | |
| | | // Load error stats from cache |
| | | $this->loadErrorStats(); |
| | |
| | | if (!empty($this->credentials['expires_at'])) { |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | if ($expires_at <= time()) { |
| | | // Token expired, try to refresh |
| | | if (!empty($this->credentials['refresh_token'])) { |
| | | return $this->refreshOAuthToken(); |
| | | } |
| | | return false; |
| | | } |
| | | } |
| | |
| | | return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.'); |
| | | } |
| | | |
| | | // Debug: Check if credentials are loaded |
| | | error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No')); |
| | | error_log('With Credentials: '.print_r($this->credentials, true)); |
| | | |
| | | $attempt = 0; |
| | | $lastError = null; |
| | |
| | | |
| | | // Retry with backoff for server errors |
| | | if ($attempt < $this->maxRetries && !$this->isClientError($e)) { |
| | | sleep($this->retryDelays[$attempt - 1] ?? 5); |
| | | $delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds |
| | | $jitter = rand(0, $delay * 0.3); // Add 0-30% jitter |
| | | usleep($delay + $jitter); |
| | | } else { |
| | | break; |
| | | } |
| | |
| | | |
| | | $this->logDebug("$method request to: $url: ".print_r($args, true)); |
| | | |
| | | // Standard WordPress HTTP API |
| | | // Use appropriate WordPress HTTP function |
| | | // Make the request |
| | | $response = match($method) { |
| | | 'GET' => wp_remote_get($url, $args), |
| | | 'POST' => wp_remote_post($url, $args), |
| | | 'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])), |
| | | default => null |
| | | }; |
| | | |
| | | if (!$response) { |
| | | $this->logError("Unsupported HTTP method $method for $this->service_name"); |
| | | return null; |
| | |
| | | $response_code = wp_remote_retrieve_response_code($response); |
| | | $body = wp_remote_retrieve_body($response); |
| | | |
| | | // Handle 401 - try to refresh token and retry once |
| | | if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) { |
| | | // Avoid infinite retry loop - only retry once |
| | | static $retry_count = 0; |
| | | |
| | | if ($retry_count === 0) { |
| | | $retry_count++; |
| | | |
| | | $this->logDebug('Got 401, attempting token refresh...'); |
| | | if ($this->refreshOAuthToken()) { |
| | | $this->logDebug('Token refreshed successfully, retrying request...'); |
| | | |
| | | // Rebuild request args with new token |
| | | $args = $this->buildRequestArgs($method, $data, $options); |
| | | |
| | | // Retry the request |
| | | $response = match($method) { |
| | | 'GET' => wp_remote_get($url, $args), |
| | | 'POST' => wp_remote_post($url, $args), |
| | | 'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])), |
| | | default => null |
| | | }; |
| | | |
| | | if ($response && !is_wp_error($response)) { |
| | | $response_code = wp_remote_retrieve_response_code($response); |
| | | $body = wp_remote_retrieve_body($response); |
| | | } |
| | | } |
| | | $retry_count = 0; // Reset for next request |
| | | } |
| | | } |
| | | |
| | | if ($response_code >= 400) { |
| | | $this->handleApiError($response_code, $body, $endpoint); |
| | | } |
| | | |
| | | $decoded = json_decode($body, true); |
| | | if (json_last_error() !== JSON_ERROR_NONE) { |
| | | return ['raw_response' => $body]; |
| | |
| | | bool $force = false |
| | | ): ?array |
| | | { |
| | | $cacheKey = $this->buildCacheKey('GET', $endpoint, $params); |
| | | $ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl; |
| | | $cacheKey = $this->buildCacheKey('GET', $endpoint, $params, $baseKey); |
| | | |
| | | $ttl = is_int($cacheStrategy) |
| | | ? max(0, $cacheStrategy) |
| | | : ($this->cacheStrategy[$cacheStrategy] ?? $this->ttl); |
| | | |
| | | if (!$force && $ttl > 0) { |
| | | $cached = $this->cache->get($cacheKey); |
| | |
| | | |
| | | $result = $this->makeRequest('GET', $endpoint, $params, $baseKey); |
| | | |
| | | if ($result && $ttl > 0) { |
| | | // Only cache successful responses (not WP_Error and not error objects) |
| | | if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) { |
| | | $this->cache->set($cacheKey, $result, $ttl); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | /** |
| | | * Check if response contains an error |
| | | * Override in child classes for service-specific error detection |
| | | */ |
| | | protected function isErrorResponse(array $response): bool |
| | | { |
| | | // Common error patterns across APIs |
| | | return isset($response['error']) |
| | | || isset($response['errors']) |
| | | || isset($response['error_description']); |
| | | } |
| | | |
| | | /** |
| | | * POST request |
| | |
| | | |
| | | public function handleAjaxResponse() |
| | | { |
| | | error_log('Ajax Response: '.print_r($_GET, true)); |
| | | |
| | | $code = $_GET['code']; |
| | | $state = $_GET['state']; |
| | | |
| | | error_log('OAuth Callback - Code: ' . $code); |
| | | error_log('OAuth Callback - State: ' . $state); |
| | | |
| | | |
| | | $state_parts = explode('|', $state); |
| | | $state_key = $state_parts[0] ?? ''; |
| | |
| | | $user_id = ($user_id === 0) ? null : $user_id; |
| | | $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations'); |
| | | |
| | | error_log('Service: '.print_r($this->service_name, true)); |
| | | $state_data = get_transient('oauth_state_' . $state_key); |
| | | error_log('State Data: '.print_r($state_data, true)); |
| | | if (!$state_data || $state_data['service'] !== $this->service_name) { |
| | | wp_die('Invalid state parameter', 'OAuth Error'); |
| | | } |
| | | |
| | | // Delete the transient to prevent reuse |
| | | delete_transient('oauth_state_' . $state_key); |
| | | error_log('Return URL: '.print_r($return_url, true)); |
| | | // Handle error from OAuth provider |
| | | if (array_key_exists('error', $_GET)) { |
| | | $error_description = $_GET['error_description'] ?? 'Authorization denied'; |
| | |
| | | } |
| | | |
| | | if (!empty($this->credentials)) { |
| | | if ($this->isOAuthService && $this->hasOAuthCredentials() && !$this->isOAuthValid()) { |
| | | $this->logDebug('OAuth token expired, attempting refresh'); |
| | | if (!$this->refreshOAuthToken()) { |
| | | $this->logError('Failed to refresh OAuth token'); |
| | | if ($this->isOAuthService && $this->hasOAuthCredentials()) { |
| | | // Check if token is expired first |
| | | if (!$this->isOAuthValid()) { |
| | | // Only attempt refresh once per request |
| | | if (!$this->token_refresh_attempted) { |
| | | $this->token_refresh_attempted = true; |
| | | $this->logDebug('OAuth token expired, attempting refresh'); |
| | | |
| | | if (!$this->refreshOAuthToken()) { |
| | | $this->logError('Failed to refresh expired OAuth token - stopping execution'); |
| | | // Token refresh failed - DO NOT continue making API requests |
| | | return; |
| | | } |
| | | } else { |
| | | // Already attempted refresh in this request |
| | | $this->logDebug('Token refresh already attempted, skipping'); |
| | | return; |
| | | } |
| | | } |
| | | // Check if we should proactively refresh (before expiry) |
| | | elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) { |
| | | $this->token_refresh_attempted = true; |
| | | $this->logDebug('OAuth token should be refreshed proactively'); |
| | | if (!$this->refreshOAuthToken()) { |
| | | $this->logError('Failed to proactively refresh OAuth token'); |
| | | // Not critical - token is still valid, so continue |
| | | } |
| | | } |
| | | } |
| | | $this->initialize(); |
| | |
| | | // Switch context |
| | | $this->userID = $user_id; |
| | | $this->credentials = []; |
| | | $this->resetTokenRefreshFlag(); // ADD THIS LINE |
| | | |
| | | $this->ensureInitialized(); |
| | | } |
| | |
| | | |
| | | $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params); |
| | | |
| | | // Debug log for troubleshooting |
| | | error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url); |
| | | |
| | | return $auth_url; |
| | | } |
| | |
| | | 'redirect_uri' => $this->getRedirectUri() |
| | | ]; |
| | | |
| | | // Use a custom endpoint key for OAuth (not part of regular API) |
| | | // We need to handle this specially since OAuth endpoints are different |
| | | $oauth_endpoint = $this->oauth['token']; |
| | | |
| | | // Make the request using the centralized method |
| | | // This automatically includes rate limiting and error handling |
| | | $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | |
| | | |
| | | // Parse response |
| | | if (isset($response['access_token'])) { |
| | | $expires_in = $response['expires_in'] ?? 2592000; // 30 days default |
| | | |
| | | return [ |
| | | 'access_token' => $response['access_token'], |
| | | 'refresh_token' => $response['refresh_token'] ?? '', |
| | | 'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default |
| | | 'expires_in' => $expires_in, |
| | | 'expires_at' => time() + $expires_in, // Calculate expiry timestamp |
| | | 'token_type' => $response['token_type'] ?? 'Bearer', |
| | | 'merchant_id' => $response['merchant_id'] ?? '', |
| | | 'scope' => $response['scope'] ?? '' |
| | |
| | | } |
| | | |
| | | /** |
| | | * Check if token should be proactively refreshed |
| | | * Different from isOAuthValid() which checks if token is actually expired |
| | | */ |
| | | protected function shouldRefreshToken(): bool |
| | | { |
| | | if (!$this->isOAuthService || $this->refresh_interval === 0) { |
| | | return false; |
| | | } |
| | | |
| | | // If no expiry info, we can't proactively refresh |
| | | if (empty($this->credentials['expires_at'])) { |
| | | return false; |
| | | } |
| | | |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | $time_until_expiry = $expires_at - time(); |
| | | |
| | | // Refresh if we're within the refresh interval window |
| | | return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval; |
| | | } |
| | | /** |
| | | * Get time until token refresh is recommended |
| | | * Useful for displaying in admin UI |
| | | */ |
| | | public function getTimeUntilRefresh(): ?int |
| | | { |
| | | if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) { |
| | | return null; |
| | | } |
| | | |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | $refresh_at = $expires_at - $this->refresh_interval; |
| | | $time_until_refresh = $refresh_at - time(); |
| | | |
| | | return max(0, $time_until_refresh); |
| | | } |
| | | |
| | | /** |
| | | * Get token freshness status |
| | | * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info' |
| | | */ |
| | | public function getTokenStatus(): string |
| | | { |
| | | if (!$this->isOAuthService) { |
| | | return 'not_oauth'; |
| | | } |
| | | |
| | | if (empty($this->credentials['access_token'])) { |
| | | return 'no_token'; |
| | | } |
| | | |
| | | if (empty($this->credentials['expires_at'])) { |
| | | return 'no_expiry_info'; |
| | | } |
| | | |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | $now = time(); |
| | | |
| | | if ($expires_at <= $now) { |
| | | return 'expired'; |
| | | } |
| | | |
| | | if ($this->shouldRefreshToken()) { |
| | | return 'should_refresh'; |
| | | } |
| | | |
| | | return 'fresh'; |
| | | } |
| | | /** |
| | | * Refresh OAuth token |
| | | */ |
| | | protected function refreshOAuthToken(): bool |
| | |
| | | $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to refresh Square token', [ |
| | | $this->logError('Failed to refresh OAuth token for '.$this->service_name, [ |
| | | 'error' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | |
| | | $this->credentials['access_token'] = $response['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days |
| | | |
| | | // Note: Square returns the SAME refresh token |
| | | // Note: Some services return the SAME refresh token |
| | | if (isset($response['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $response['refresh_token']; |
| | | } |
| | |
| | | switch ($action) { |
| | | case 'save_credentials': |
| | | $title = $label; |
| | | $label = jvbIcon('save'); |
| | | $label = jvbIcon('floppy-disk'); |
| | | break; |
| | | case 'clear_credentials': |
| | | $title = $label; |
| | |
| | | } |
| | | $credentials = $this->getCredentials(); |
| | | $hasCredentials = $this->hasOAuthCredentials(); |
| | | $returnURL = (is_admin()) ? :get_the_permalink(); |
| | | $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url()); |
| | | ?> |
| | | |
| | | <details <?= $hasCredentials?' open':''?>> |
| | |
| | | throw new Exception('Failed to save JPEG image'); |
| | | } |
| | | } |
| | | /** |
| | | * Reset token refresh attempt flag |
| | | * Called automatically when switching users |
| | | */ |
| | | protected function resetTokenRefreshFlag(): void |
| | | { |
| | | $this->token_refresh_attempted = false; |
| | | } |
| | | } |