| | |
| | | namespace JVBase\integrations; |
| | | |
| | | use Exception; |
| | | use JVBase\managers\CacheManager; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\UploadManager; |
| | | use JVBase\meta\MetaManager; |
| | | use JVBase\managers\ErrorHandler; |
| | | use JVBase\registrar\Registrar; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | */ |
| | | abstract class Integrations |
| | | { |
| | | use _Base; |
| | | |
| | | /** |
| | | * API Configuration |
| | | * These properties define how the integration connects to external services |
| | | */ |
| | | protected string $service_name; // Unique identifier for this service (e.g., 'square', 'facebook') |
| | | protected string|array $apiBase = ''; // Base URL(s) for API endpoints. Array format: ['base' => '', 'auth' => ''] |
| | | 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 |
| | | * Set these properties if your service uses OAuth authentication |
| | | */ |
| | | protected bool $isOAuthService = false; |
| | | protected array $oauth = [ |
| | | 'authorize' => '', // OAuth authorization endpoint |
| | | 'token' => '', // Token exchange endpoint |
| | | 'revoke' => '', // Token revocation endpoint (optional) |
| | | 'scopes' => [], // Required OAuth scopes |
| | | 'redirect_uri' => '', // OAuth callback URL |
| | | ]; |
| | | |
| | | /** |
| | | * Display Properties |
| | | * Used for UI rendering in admin interfaces |
| | | */ |
| | | public string $title; // Human-readable service name (e.g., 'Google My Business') |
| | | public string $icon; // Phosphoricons icon slug |
| | | |
| | | /** |
| | | * Credentials & State |
| | | */ |
| | | protected array $credentials = []; // Service credentials (API keys, tokens, etc.) |
| | | protected ?int $userID = null; // User context for user-specific integrations |
| | | |
| | | |
| | | protected array $fields = []; // The fields to generate that become credentials |
| | | protected array $advanced = []; // The fields that are optional settings |
| | | protected array $defaults = []; // Default overrides |
| | | protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes |
| | | protected array $instructions = []; // Each item in array becomes an item in the list of instructions |
| | | |
| | | protected bool $supportsWebp = true; |
| | | /** |
| | | * Client Management |
| | | * For services that benefit from persistent connections |
| | | */ |
| | | protected ?object $client = null; // Persistent client instance |
| | | |
| | | protected array $actions = [ |
| | | 'save_credentials' => 'saveCredentials', |
| | | 'clear_credentials' => 'deleteCredentials', |
| | | 'clear_cache' => 'clearCache', |
| | | 'test_connection' => 'testConnection', |
| | | ]; |
| | | |
| | | protected array $buttons = [ |
| | | 'save_credentials' => 'Save Changes', |
| | | 'clear_credentials' => 'Clear Credentials', |
| | | 'clear_cache'=> 'Clear Cache', |
| | | 'test_connection' => 'Test Connection', |
| | | ]; |
| | | |
| | | protected array $allowedContent = []; |
| | | |
| | | /** |
| | | * Caching Configuration |
| | | */ |
| | | protected ?string $cacheName = null; |
| | | protected CacheManager $cache; |
| | | protected array $cacheStrategy = [ |
| | | 'aggressive' => 3600, // 1 hour for stable data (e.g., profile info) |
| | | 'moderate' => 300, // 5 minutes for semi-dynamic data (e.g., posts) |
| | | 'minimal' => 60, // 1 minute for frequently changing data |
| | | 'none' => 0 // No caching for real-time data |
| | | ]; |
| | | protected int $ttl = 3600; // Default cache TTL in seconds |
| | | |
| | | /** |
| | | * Rate Limiting Configuration |
| | | * Override these values based on service-specific limits |
| | | */ |
| | | protected array $rate_limits = [ |
| | | 'per_second' => 2, |
| | | 'per_minute' => 30, |
| | | 'per_hour' => 1000 |
| | | ]; |
| | | protected array $oauth_rate_limits = [ |
| | | 'per_second' => 1, // Max 1 OAuth request per second |
| | | 'per_minute' => 10, // Max 10 OAuth requests per minute |
| | | 'per_hour' => 100, // Max 100 OAuth requests per hour |
| | | ]; |
| | | protected array $request_history = []; |
| | | protected int $max_history_size = 100; |
| | | |
| | | /** |
| | | * Post Syncing Capabilities |
| | | * Define what sync operations this integration supports |
| | | */ |
| | | protected array $canSync = [ |
| | | 'initial' => false, // Can share new posts to the service |
| | | 'update' => false, // Can update already-shared posts |
| | | 'delete' => false, // Can remove posts from the service |
| | | ]; |
| | | protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []] |
| | | protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []] |
| | | protected array $contentTypes = []; // Integration's available content types. Set by child classes' getContentTypes |
| | | protected bool $has_content = false; // Whether integration has content that can sync |
| | | /** |
| | | * Error Handling Configuration |
| | | */ |
| | | protected array $lastError = []; |
| | | protected int $maxRetries = 3; |
| | | protected array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds |
| | | |
| | | /** |
| | | * Validation Rules |
| | | * Define validation rules for credentials and data |
| | | */ |
| | | protected array $validationRules = []; |
| | | |
| | | /** |
| | | * Error tracking properties |
| | | */ |
| | | protected array $error_stats = [ |
| | | 'total_errors' => 0, |
| | | 'consecutive_errors' => 0, |
| | | 'last_success' => null, |
| | | 'error_types' => [] |
| | | ]; |
| | | |
| | | protected ?ErrorHandler $errorHandler = null; |
| | | protected int $error_threshold = 5; // Consecutive errors before marking unhealthy |
| | | protected bool $is_healthy = true; |
| | | protected bool $handleWebhooks = false; |
| | | |
| | | public function __construct(?int $userID = null) |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->cacheName = $this->cacheName ?: $this->service_name; |
| | | $this->userID = $userID; |
| | | $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl); |
| | | |
| | | // Load error stats from cache |
| | | $this->loadErrorStats(); |
| | | $this->cache = Cache::for('integrations_' . $this->cacheName); |
| | | |
| | | $this->getPostTypes(); |
| | | $this->getTaxonomies(); |
| | | $this->setContentTypes(); |
| | | $this->getUsers(); |
| | | |
| | | if (method_exists($this, 'setContentTypes')) { |
| | | $this->setContentTypes(); |
| | | } |
| | | $this->registerHooks(); |
| | | if ($this->isOAuthService) { |
| | | $this->actions['get_oauth_url'] = 'getOAuthUrlAction'; |
| | | $this->actions['connect_oauth'] = 'handleOAuthConnect'; |
| | | $this->actions['disconnect_oauth'] = 'handleOAuthDisconnect'; |
| | | $this->actions['refresh_token'] = 'refreshOAuthToken'; |
| | | $this->buttons['refresh_token'] = 'Refresh OAuth Connection'; |
| | | $this->buttons['disconnect_oauth'] = 'Disconnect OAuth'; |
| | | } |
| | | if ($this->handleWebhooks) { |
| | | $this->advanced['webhook_signature_key'] = [ |
| | | 'label' => 'Webhook Signature Key', |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'placeholder' => 'Enter webhook signature key', |
| | | 'hint' => 'Used to verify webhook authenticity (recommended)' |
| | | ]; |
| | | |
| | | if (method_exists($this, 'setQueueTypes')) { |
| | | $this->setQueueTypes(); |
| | | } |
| | | |
| | | add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4); |
| | | } |
| | | $this->initializeRateLimiters(); |
| | | |
| | | protected function setContentTypes():void |
| | | { |
| | | |
| | | } |
| | | public function getContentTypes(string $type):array |
| | | { |
| | | |
| | | return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes; |
| | | } |
| | | |
| | | public function checkRenderField($shouldRender, $name, $type, $objectType):bool |
| | | { |
| | | if ($type !== 'form') { |
| | | return $shouldRender; |
| | | if (method_exists($this, 'initializeActions')) { |
| | | $this->initializeActions(); |
| | | } |
| | | |
| | | if (!$this->isSetUp() && str_contains($name, $this->service_name)) { |
| | | return false; |
| | | if (method_exists($this, 'addOAuthActions')) { |
| | | $this->addOAuthActions(); |
| | | } |
| | | return $shouldRender; |
| | | } |
| | | |
| | | public function handleOAuthConnect(): array |
| | | { |
| | | if (!$this->isOAuthService) { |
| | | return ['success' => false, 'message' => 'Not an OAuth service']; |
| | | } |
| | | |
| | | // This would typically redirect to OAuth provider |
| | | // Child classes can override for specific behavior |
| | | $auth_url = $this->getOAuthUrl(); |
| | | |
| | | if ($auth_url) { |
| | | return [ |
| | | 'success' => true, |
| | | 'redirect' => $auth_url, |
| | | ]; |
| | | } |
| | | |
| | | return ['success' => false, 'message' => 'Failed to generate OAuth URL']; |
| | | } |
| | | |
| | | /** |
| | | * Check if OAuth token is valid and not expired |
| | | * @return bool |
| | | */ |
| | | public function isOAuthValid(): bool |
| | | { |
| | | if (!$this->isOAuthService) { |
| | | return false; |
| | | } |
| | | |
| | | // Check if we have tokens |
| | | if (empty($this->credentials['access_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | // Check token expiry if stored |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | // For services without expiry info, do a test API call |
| | | return true; |
| | | // return $this->testConnection(); |
| | | } |
| | | |
| | | |
| | | |
| | | public function getOAuthUrlAction(array $data = []): array |
| | | { |
| | | if (!$this->isOAuthService) { |
| | | return ['success' => false, 'message' => 'Not an OAuth service']; |
| | | } |
| | | |
| | | $return_url = $data['return_url'] ?? null; |
| | | $auth_url = $this->getOAuthUrl($return_url); |
| | | |
| | | if ($auth_url) { |
| | | return [ |
| | | 'success' => true, |
| | | 'auth_url' => $auth_url, |
| | | 'popup' => true |
| | | ]; |
| | | } |
| | | |
| | | return ['success' => false, 'message' => 'Failed to generate OAuth URL']; |
| | | } |
| | | |
| | | protected function getPostTypes(): void |
| | | { |
| | | $key = BASE . $this->service_name . '_sync_post_types'; |
| | | $postTypes = get_option($key, false); |
| | | if (!$postTypes) { |
| | | $postTypes = []; |
| | | $this->syncPostTypes = Registrar::withIntegration($this->service_name, 'post'); |
| | | } |
| | | |
| | | // Get from JVB_CONTENT |
| | | |
| | | foreach (JVB_CONTENT as $type => $config) { |
| | | if (array_key_exists('integrations', $config) && array_key_exists($this->service_name, $config['integrations'])) { |
| | | $postTypes[] = $type; |
| | | } |
| | | } |
| | | |
| | | update_option($key, $postTypes); |
| | | } |
| | | |
| | | $this->syncPostTypes = $postTypes; |
| | | protected function getUsers():void |
| | | { |
| | | $this->syncUsers = Registrar::withIntegration($this->service_name, 'user'); |
| | | } |
| | | |
| | | protected function getTaxonomies():void |
| | | { |
| | | $key = BASE . $this->service_name . '_sync_taxonomies'; |
| | | $taxonomies = get_option($key, false); |
| | | |
| | | if (!$taxonomies) { |
| | | // Combine both content and taxonomy filtering |
| | | $taxonomies = []; |
| | | // Get from JVB_TAXONOMY (content taxonomies) |
| | | foreach (JVB_TAXONOMY as $type => $config) { |
| | | if (jvbCheck('is_content', $config) && |
| | | isset($config['integrations'][$this->service_name])) { |
| | | $taxonomies[] = $type; |
| | | } |
| | | } |
| | | |
| | | update_option($key, $taxonomies); |
| | | } |
| | | |
| | | $this->syncTaxonomies = $taxonomies; |
| | | } |
| | | |
| | | protected function getRedirectUri(): string |
| | | { |
| | | // if (!empty($this->oauth['redirect_uri'])) { |
| | | // return $this->oauth['redirect_uri']; |
| | | // } |
| | | if ($this->isOAuthService) { |
| | | return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback'); |
| | | } |
| | | // Changed from admin-ajax.php to REST endpoint |
| | | return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name); |
| | | } |
| | | |
| | | /** |
| | | * Used by IntegrationsRoutes.php |
| | | * @param string $code |
| | | * @param string $state |
| | | * @return array |
| | | */ |
| | | public function handleOAuthCode(string $code, string $state): array |
| | | { |
| | | try { |
| | | $this->loadCredentials(); |
| | | $tokens = $this->exchangeOAuthCode($code); |
| | | |
| | | if (!$tokens) { |
| | | return ['success' => false, 'message' => 'Failed to exchange authorization code']; |
| | | } |
| | | |
| | | $credentials = array_merge($this->credentials, $tokens); |
| | | $credentials = $this->addCredentialData($credentials, $tokens); |
| | | $saved = $this->saveCredentials($credentials, false); |
| | | |
| | | if ($saved) { |
| | | return ['success' => true, 'message' => 'Successfully connected']; |
| | | } |
| | | |
| | | return ['success' => false, 'message' => 'Failed to save credentials']; |
| | | } catch (Exception $e) { |
| | | $this->logError('OAuth code exchange failed', ['error' => $e->getMessage()]); |
| | | return ['success' => false, 'message' => 'OAuth error: ' . $e->getMessage()]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * @param string $action |
| | | * @param mixed|null $data |
| | | * @return array [ |
| | | * 'success' => {bool}, |
| | | * 'message' => {string} optional message, |
| | | * 'data' => {mixed} optional data to return |
| | | * ] |
| | | */ |
| | | public function processAction(string $action, mixed $data = null):array |
| | | { |
| | | if (!$this->checkCapabilities($action, $this->userID)) { |
| | | $this->logError('User cannot perform this action'); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Insufficient permissions' |
| | | ]; |
| | | } |
| | | |
| | | if (!array_key_exists($action, $this->actions)) { |
| | | $this->logError('Attempted action not set up', [ |
| | | 'action' => $action, |
| | | 'stored' => $this->actions, |
| | | 'service' => $this->service_name, |
| | | 'method' => 'processAction' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Invalid action' |
| | | ]; |
| | | } |
| | | if (!method_exists($this, $this->actions[$action])) { |
| | | $this->logError('Attempted method not set up', [ |
| | | 'attempted' => $this->actions['action'], |
| | | 'service' => $this->service_name, |
| | | 'method' => 'processAction' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Action not set up' |
| | | ]; |
| | | } |
| | | |
| | | $method = $this->actions[$action]; |
| | | // Log the action for debugging |
| | | $this->logDebug("Processing action", [ |
| | | 'action' => $action, |
| | | 'method' => $method, |
| | | 'data' => $data |
| | | ]); |
| | | if ($data !== null) { |
| | | $result = $this->$method($data); |
| | | } else { |
| | | $result = $this->$method(); |
| | | } |
| | | error_log('Action result: '.print_r($result, true)); |
| | | if (is_wp_error($result)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $result->get_error_message() |
| | | ]; |
| | | } |
| | | |
| | | return is_array($result) ? $result : ['success' => true, 'data' => $result]; |
| | | |
| | | $this->syncTaxonomies = Registrar::withIntegration($this->service_name, 'term'); |
| | | } |
| | | |
| | | /********************************************************************* |
| | |
| | | */ |
| | | abstract protected function initialize(): void; |
| | | |
| | | /** |
| | | * Get request headers for API calls |
| | | * |
| | | * Return an associative array of headers required for API requests. |
| | | * Common headers include Authorization, Content-Type, Accept, etc. |
| | | * |
| | | * Example: |
| | | * return [ |
| | | * 'Authorization' => 'Bearer ' . $this->credentials['access_token'], |
| | | * 'Content-Type' => 'application/json', |
| | | * 'Accept' => 'application/json', |
| | | * ]; |
| | | * |
| | | * @return array Associative array of header names and values |
| | | */ |
| | | abstract protected function getRequestHeaders(): array; |
| | | |
| | | /** |
| | | * Render the connection settings form |
| | |
| | | * OPTIONAL OVERRIDE METHODS - IMPLEMENT AS NEEDED |
| | | *********************************************************************/ |
| | | /** |
| | | * Save credentials using CredentialsManager |
| | | * Save credentials using Auth |
| | | */ |
| | | public function saveCredentials(array $credentials, bool $test = true):array |
| | | { |
| | | try { |
| | | // Process and validate credentials |
| | | if (!$this->validateCredentials($credentials)) { |
| | | $this->logError('Invalid credentials'); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Credentials not formatted correctly' |
| | | ]; |
| | | $this->logError('saveCredentials', 'Invalid credentials'); |
| | | return $this->response(false, 'Credentials not formatted correctly'); |
| | | } |
| | | |
| | | //Merge the new credentials with the old ones (new stuff overriding any old stuff) |
| | | $old = $this->getCredentials(); |
| | | $credentials = array_merge($old, $credentials); |
| | | |
| | | // Temporarily set credentials for testing |
| | | $this->credentials = $credentials; |
| | | $this->initialize(); |
| | | |
| | | // Test the connection before saving |
| | | if ($test) { |
| | | if (!$this->testConnection(true)) { |
| | | $this->logError('Connection test failed'); |
| | | // Revert to old credentials |
| | | $this->credentials = $old; |
| | | $this->initialize(); |
| | | $this->logError('saveCredentials', 'Connection test failed'); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection failed. Please check your credentials.', |
| | | 'test_failed' => true |
| | | ]; |
| | | return $this->response(false, 'Connection failed. Please check your credentials'); |
| | | } |
| | | } |
| | | |
| | | |
| | | // Connection successful, save credentials |
| | | $stored = CredentialsManager::getInstance()->storeCredentials( |
| | | $stored = Auth::getInstance()->storeCredentials( |
| | | $this->service_name, |
| | | $credentials, |
| | | $this->userID |
| | |
| | | $this->updateLastTestedTime(); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Credentials validated and saved successfully', |
| | | 'reload' => true |
| | | ]; |
| | | return $this->response(true, 'Credentials validated and saved successfully'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to save credentials to database' |
| | | ]; |
| | | return $this->response(false, 'Failed to save credentials to database'); |
| | | |
| | | } catch (\Exception $e) { |
| | | // Revert to old credentials on any error |
| | | $old = CredentialsManager::getInstance()->getCredentials( |
| | | $old = Auth::getInstance()->getCredentials( |
| | | $this->service_name, |
| | | $this->userID |
| | | ); |
| | | $this->credentials = $old; |
| | | $this->initialize(); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Validation error: ' . $e->getMessage() |
| | | ]; |
| | | return $this->response(false, 'Validation error: '.print_r($e->getMessage(), true)); |
| | | } |
| | | } |
| | | |
| | |
| | | */ |
| | | public function deleteCredentials():array |
| | | { |
| | | $success = CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID); |
| | | return [ |
| | | 'success' => $success |
| | | ]; |
| | | $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); |
| | | return $this->response(true, 'Deleted successfully'); |
| | | } |
| | | /** |
| | | * Validate credentials before storing |
| | |
| | | } |
| | | |
| | | try { |
| | | error_log('Credentials to save: '.print_r($this->credentials, true)); |
| | | if ($this->isOAuthService && !$this->hasOAuthCredentials()){ |
| | | error_log('Just saving credentials, we don\'t have OAuth setup yet...'); |
| | | //If this is an OAuth service, we might only be saving the app credentials first |
| | | $result = true; |
| | | } else { |
| | |
| | | |
| | | protected function clearCache():array |
| | | { |
| | | $success = $this->cache->clear(); |
| | | $this->cache->flush(); |
| | | return [ |
| | | 'success' => $success, |
| | | 'success' => true, |
| | | ]; |
| | | } |
| | | |
| | |
| | | /****************************************************************** |
| | | POST SYNC |
| | | ******************************************************************/ |
| | | /** |
| | | * Handle post save for syncing |
| | | * |
| | | * Override to implement custom sync logic when posts are saved. |
| | | * Check the $settings array for post type specific configuration. |
| | | * |
| | | * @param int $postID The post ID |
| | | * @param WP_Post $post The post object |
| | | * @param bool $update Whether this is an update |
| | | * @param array $settings Post type integration settings |
| | | * @return void |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | // Override in child classes that support post syncing |
| | | // Example implementation: |
| | | /* |
| | | $fields = $this->getSyncFields($postID, 'post', ['schedule_' . $this->service_name]); |
| | | $options = $fields['schedule_' . $this->service_name] !== '' |
| | | ? ['scheduled' => strtotime($fields['schedule_' . $this->service_name])] |
| | | : []; |
| | | |
| | | $this->queueOperation( |
| | | 'sync_post', |
| | | ['post_id' => $postID, 'is_update' => $update], |
| | | $options |
| | | ); |
| | | */ |
| | | } |
| | | |
| | | /********************************************************************* |
| | | * API REQUEST METHODS |
| | | *********************************************************************/ |
| | | |
| | | /** |
| | | * Make an API request with automatic retry and error handling |
| | | * @param string $method |
| | | * @param string $endpoint |
| | | * @param array $data |
| | | * @param string|null $baseKey array key for the $this->apiEndpoints |
| | | * @param array $options |
| | | * @return WP_Error|array |
| | | */ |
| | | protected function makeRequest( |
| | | string $method, |
| | | string $endpoint, |
| | | array $data = [], |
| | | ?string $baseKey = null, |
| | | array $options = [] |
| | | ): array|WP_Error |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->isSetUp()){ |
| | | $this->logError('Connection not setup for '.$this->service_name, [ |
| | | 'method' => 'makeRequest', |
| | | ]); |
| | | return new WP_Error('Error', 'Connection is not set up to make request'); |
| | | } |
| | | |
| | | // Rate limiting check |
| | | if (!$this->checkRateLimit()) { |
| | | 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; |
| | | |
| | | while ($attempt < $this->maxRetries) { |
| | | try { |
| | | $this->logDebug('[Integrations] Making request to '.$this->service_name); |
| | | $result = $this->executeRequest($method, $endpoint, $data, $baseKey, $options); |
| | | if (!$result) { |
| | | return new WP_Error('Request Error'); |
| | | } |
| | | $this->recordRequest($method, $endpoint); |
| | | $this->logDebug('[Integrations]', [ |
| | | 'response' => $result |
| | | ]); |
| | | // Reset error stats on success |
| | | $this->resetErrorStats(); |
| | | return $result; |
| | | |
| | | } catch (Exception $e) { |
| | | $lastError = $e; |
| | | $this->recordRequest($method, $endpoint); |
| | | $attempt++; |
| | | |
| | | // Retry with backoff for server errors |
| | | if ($attempt < $this->maxRetries && !$this->isClientError($e)) { |
| | | $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->logError('API request failed after retries', [ |
| | | 'method' => $method, |
| | | 'endpoint' => $endpoint, |
| | | 'attempts' => $attempt, |
| | | 'error' => $lastError ? $lastError->getMessage() : 'Unknown error' |
| | | ]); |
| | | |
| | | return new WP_Error('api_error', $lastError ? $lastError->getMessage() : 'API request failed'); |
| | | } |
| | | |
| | | /** |
| | | * Execute the actual request |
| | | */ |
| | | protected function executeRequest( |
| | | string $method, |
| | | string $endpoint, |
| | | array $data = [], |
| | | ?string $baseKey = null, |
| | | array $options = [] |
| | | ): ?array |
| | | { |
| | | $args = $this->buildRequestArgs($method, $data, $options); |
| | | $url = $this->getApiUrl($endpoint, $baseKey); |
| | | if (!$url) { |
| | | return null; |
| | | } |
| | | |
| | | $this->logDebug("$method request to: $url: ".print_r($args, true)); |
| | | |
| | | // 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; |
| | | } |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError("Error for: $this->service_name in executeRequest", ['message' => $response->get_error_message()]); |
| | | 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]; |
| | | } |
| | | |
| | | $this->updateLastTestedTime(); |
| | | |
| | | return $decoded; |
| | | } |
| | | |
| | | /** |
| | | * GET request with caching |
| | | */ |
| | | protected function getRequest( |
| | | string $endpoint, |
| | | array $params = [], |
| | | ?string $baseKey = null, |
| | | string $cacheStrategy = 'moderate', |
| | | bool $force = false |
| | | ): ?array |
| | | { |
| | | $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); |
| | | if ($cached !== false) { |
| | | $this->logDebug("Cache hit for: $cacheKey"); |
| | | return $cached; |
| | | } |
| | | } |
| | | |
| | | $result = $this->makeRequest('GET', $endpoint, $params, $baseKey); |
| | | |
| | | // 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 |
| | | */ |
| | | protected function postRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array |
| | | { |
| | | return $this->makeRequest('POST', $endpoint, $data, $baseKey); |
| | | } |
| | | |
| | | /** |
| | | * PUT request |
| | | */ |
| | | protected function putRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array |
| | | { |
| | | return $this->makeRequest('PUT', $endpoint, $data, $baseKey); |
| | | } |
| | | |
| | | /** |
| | | * DELETE request |
| | | */ |
| | | protected function deleteRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array |
| | | { |
| | | return $this->makeRequest('DELETE', $endpoint, $data, $baseKey); |
| | | } |
| | | |
| | | /** |
| | | * PATCH request |
| | | */ |
| | | protected function patchRequest(string $endpoint, array $data = [], ?string $baseKey = null): WP_Error|array |
| | | { |
| | | return $this->makeRequest('PATCH', $endpoint, $data, $baseKey); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Build request arguments |
| | | */ |
| | | protected function buildRequestArgs(string $method, array $data, array $options): array |
| | | { |
| | | $args = [ |
| | | 'method' => $method, |
| | | 'headers' => $this->getRequestHeaders(), |
| | | 'timeout' => $options['timeout'] ?? 30, |
| | | 'sslverify' => $options['sslverify'] ?? true, |
| | | 'redirection' => $options['redirection'] ?? 5, |
| | | 'blocking' => $options['blocking'] ?? true, |
| | | 'compress' => $options['compress'] ?? true, |
| | | 'decompress' => $options['decompress'] ?? true, |
| | | ]; |
| | | |
| | | if ($method === 'GET' && !empty($data)) { |
| | | // Add query parameters for GET requests |
| | | $args['body'] = $data; |
| | | } elseif (!empty($data)) { |
| | | $content_type = $args['headers']['Content-Type'] ?? 'application/json'; |
| | | |
| | | if (str_contains($content_type, 'application/json')) { |
| | | $args['body'] = json_encode($data); |
| | | } elseif (str_contains($content_type, 'application/x-www-form-urlencoded')) { |
| | | $args['body'] = http_build_query($data); |
| | | } else { |
| | | $args['body'] = $data; |
| | | } |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | /********************************************************************* |
| | | * SYNC METHODS |
| | |
| | | ):bool { |
| | | $queue = JVB()->queue(); |
| | | |
| | | // Add service prefix to operation type |
| | | $operation_type = strtolower($this->service_name) . '_' . $type; |
| | | |
| | | $queued = $queue->queueOperation( |
| | | $operation_type, |
| | | $type, |
| | | $this->userID ?? 0, |
| | | array_merge($data, ['service' => $this->service_name, 'user' => $this->userID??0]), |
| | | array_merge([ |
| | |
| | | *********************************************************************/ |
| | | |
| | | /** |
| | | * Get cache duration based on endpoint |
| | | * |
| | | * @param string $endpoint The API endpoint |
| | | * @return int Cache duration in seconds |
| | | */ |
| | | protected function getCacheDuration(string $endpoint): int |
| | | { |
| | | // Override in child classes for endpoint-specific caching |
| | | // Example: user profiles = aggressive, posts = moderate, analytics = minimal |
| | | return $this->ttl; |
| | | } |
| | | |
| | | /** |
| | | * Check if we're within rate limits |
| | | * |
| | | * @return bool True if within limits |
| | | */ |
| | | protected function checkRateLimit(): bool |
| | | { |
| | | $now = time(); |
| | | |
| | | // Clean old history |
| | | $this->request_history = array_filter($this->request_history, function($timestamp) use ($now) { |
| | | return ($now - $timestamp) < 3600; // Keep last hour |
| | | }); |
| | | |
| | | // Trim to max size |
| | | if (count($this->request_history) > $this->max_history_size) { |
| | | $this->request_history = array_slice($this->request_history, -$this->max_history_size); |
| | | } |
| | | |
| | | // Check limits |
| | | $recent_counts = [ |
| | | 'second' => 0, |
| | | 'minute' => 0, |
| | | 'hour' => count($this->request_history) |
| | | ]; |
| | | |
| | | foreach ($this->request_history as $timestamp) { |
| | | if ($now - $timestamp <= 1) $recent_counts['second']++; |
| | | if ($now - $timestamp <= 60) $recent_counts['minute']++; |
| | | } |
| | | |
| | | if ($recent_counts['second'] >= $this->rate_limits['per_second']) return false; |
| | | if ($recent_counts['minute'] >= $this->rate_limits['per_minute']) return false; |
| | | if ($recent_counts['hour'] >= $this->rate_limits['per_hour']) return false; |
| | | |
| | | return true; |
| | | } |
| | | |
| | | protected function checkOAuthRateLimit(): bool |
| | | { |
| | | $cache_key = 'oauth_rate_limit_' . $this->service_name . '_' . $this->userID; |
| | | $attempts = get_transient($cache_key) ?: []; |
| | | |
| | | $now = time(); |
| | | |
| | | // Clean old attempts |
| | | $attempts = array_filter($attempts, function($timestamp) use ($now) { |
| | | return ($now - $timestamp) < 3600; // Keep last hour |
| | | }); |
| | | |
| | | // Check per-minute limit |
| | | $recent = array_filter($attempts, function($timestamp) use ($now) { |
| | | return ($now - $timestamp) < 60; |
| | | }); |
| | | |
| | | if (count($recent) >= 5) { // Max 5 OAuth attempts per minute |
| | | return false; |
| | | } |
| | | |
| | | // Add current attempt |
| | | $attempts[] = $now; |
| | | |
| | | // Save updated attempts |
| | | set_transient($cache_key, $attempts, 3600); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Record a request for rate limiting |
| | | * |
| | | * @param string $method HTTP method |
| | | * @param string $endpoint API endpoint |
| | | * @return void |
| | | */ |
| | | protected function recordRequest(string $method, string $endpoint): void |
| | | { |
| | | $this->request_history[] = time(); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Register WordPress hooks based on capabilities |
| | | */ |
| | | protected function registerHooks(): void |
| | | { |
| | | add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | add_action( 'wp_ajax_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] ); |
| | | add_action( 'wp_ajax_nopriv_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] ); |
| | | if ($this->handleWebhooks){ |
| | | //Handled by IntegrationExecutor now |
| | | // add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | |
| | | if (method_exists($this, 'registerOAuthCallbacks')) { |
| | | $this->registerOAuthCallbacks(); |
| | | } |
| | | if (method_exists($this, 'registerWebhookEndpoint')) { |
| | | $this->registerWebhookEndpoint(); |
| | | } |
| | | // Let child classes register additional hooks if needed |
| | | $this->registerAdditionalHooks(); |
| | | |
| | | |
| | | if (!empty($this->syncPostTypes)) { |
| | | add_action('save_post', [$this, 'handleSavePost'], 20, 3); |
| | | $this->addSavePost(); |
| | | add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); |
| | | |
| | | if ($this->canSync['delete']) { |
| | |
| | | add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2); |
| | | } |
| | | } |
| | | |
| | | if (method_exists($this, 'registerQueueTypes')) { |
| | | add_action('init', [$this, 'registerQueueTypes'], 10); |
| | | } |
| | | } |
| | | |
| | | 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 = intval($state_parts[1] ?? 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'; |
| | | |
| | | wp_redirect(add_query_arg([ |
| | | 'error' => 'OAuth authorization denied: ' . $error_description |
| | | ], $return_url)); |
| | | exit; |
| | | } |
| | | |
| | | // Get integration instance |
| | | $integration = JVB()->connect($this->service_name, $user_id); |
| | | |
| | | if (!$integration) { |
| | | wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error'); |
| | | } |
| | | |
| | | |
| | | // Exchange code for tokens |
| | | $result = $integration->handleOAuthCode($code, $state); |
| | | |
| | | // Redirect back with result |
| | | if ($result['success']) { |
| | | wp_redirect(add_query_arg([ |
| | | 'success' => 'Successfully connected to ' . $integration->title |
| | | ], $return_url)); |
| | | } else { |
| | | // Handle failure |
| | | $error_message = $result['message'] ?? 'Failed to complete OAuth authorization'; |
| | | |
| | | wp_redirect(add_query_arg([ |
| | | 'error' => $error_message |
| | | ], $return_url)); |
| | | } |
| | | exit; |
| | | } |
| | | /** |
| | | * Handle connection setup and credential storage |
| | | * |
| | |
| | | } |
| | | |
| | | // Store credentials |
| | | $this->credentials = array_merge($this->credentials ?? [], $sanitized); |
| | | $this->credentials['last_updated'] = time(); |
| | | $credentials = array_merge($this->loadCredentials() ?? [], $sanitized); |
| | | $credentials['last_updated'] = time(); |
| | | |
| | | // Save to database |
| | | $saved = $this->saveCredentials($this->credentials); |
| | | $saved = $this->saveCredentials($credentials); |
| | | |
| | | if (!$saved) { |
| | | return [ |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Load credentials from secure storage |
| | | */ |
| | | protected function loadCredentials(): void |
| | | { |
| | | $this->credentials = CredentialsManager::getInstance()->getCredentials($this->service_name, $this->userID); |
| | | } |
| | | |
| | | public function getCredentials(): array |
| | | { |
| | | $this->ensureInitialized(); |
| | | return $this->credentials; |
| | | return $this->loadCredentials(); |
| | | } |
| | | |
| | | /** |
| | | * Check if integration is properly configured |
| | | */ |
| | | public function isSetUp(): bool |
| | | |
| | | |
| | | public function hasOAuthCredentials(?int $userID = null): bool |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | if ($userID !== $this->userID) { |
| | | $this->switchUser($userID); |
| | | } |
| | | return !empty($this->credentials); |
| | | } |
| | | |
| | | public function hasOAuthCredentials(): bool |
| | | { |
| | | if (!$this->isOAuthService){ |
| | | return false; |
| | | } |
| | | |
| | | $required = ['client_id', 'client_secret', 'access_token']; |
| | | |
| | | foreach ($required as $field) { |
| | | if (empty($this->credentials[$field]) || !is_string($this->credentials[$field]) || $this->credentials[$field] === '') { |
| | | return false; |
| | | } |
| | | } |
| | | return true; |
| | | return $this->hasOAuth && $this->hasValidOAuth(); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Ensure service is initialized |
| | | * TODO: I don't think this is necessary anymore. |
| | | */ |
| | | protected function ensureInitialized(): void |
| | | { |
| | | if (!$this->isSetUp()){ |
| | | return; |
| | | } |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | if (!empty($this->credentials)) { |
| | | if ($this->isOAuthService && $this->hasOAuthCredentials()) { |
| | | // Check if token is expired first |
| | | if (!$this->isOAuthValid()) { |
| | | $this->logDebug('OAuth token expired, attempting refresh'); |
| | | if (!$this->refreshOAuthToken()) { |
| | | $this->logError('Failed to refresh expired OAuth token'); |
| | | } |
| | | } |
| | | // Check if we should proactively refresh (before expiry) |
| | | elseif ($this->shouldRefreshToken()) { |
| | | $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 |
| | | } |
| | | } |
| | | } |
| | | $this->initialize(); |
| | | } |
| | | $this->initialize(); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Switch user context |
| | | */ |
| | | public function switchUser(int $user_id): void |
| | | { |
| | | if ($this->userID === $user_id) { |
| | | return; |
| | | } |
| | | |
| | | // Clean up current context |
| | | $this->cleanup(); |
| | | |
| | | // Switch context |
| | | $this->userID = $user_id; |
| | | $this->credentials = []; |
| | | |
| | | $this->ensureInitialized(); |
| | | } |
| | | |
| | | public function getAsUser(int $user_id) { |
| | | return new $this($user_id); |
| | | } |
| | | |
| | | /** |
| | | * Clean up resources |
| | | */ |
| | | protected function cleanup(): void |
| | | { |
| | | // Clear sensitive data |
| | | $this->credentials = []; |
| | | |
| | | // Clear request history |
| | | $this->request_history = []; |
| | | } |
| | | |
| | | /** |
| | | * Destructor - ensure cleanup |
| | | */ |
| | | public function __destruct() |
| | | { |
| | | $this->cleanup(); |
| | | } |
| | | |
| | | |
| | | /*************************************************************** |
| | | ERROR HANDLING |
| | | ***************************************************************/ |
| | | /** |
| | | * Handle error responses |
| | | */ |
| | | protected function handleApiError(int $code, string $body, string $endpoint): void |
| | | { |
| | | $message = "API Error ({$code}): "; |
| | | $decoded = json_decode($body, true); |
| | | |
| | | // Extract error details |
| | | $error_details = $this->extractErrorDetails($decoded, $body); |
| | | $message .= $error_details['message']; |
| | | |
| | | // Determine error severity based on HTTP code |
| | | $severity = $this->getErrorSeverity($code); |
| | | |
| | | // Build comprehensive error context |
| | | $error_context = [ |
| | | 'service' => $this->service_name, |
| | | 'endpoint' => $endpoint, |
| | | 'http_code' => $code, |
| | | 'error_type' => $this->categorizeApiError($code), |
| | | 'error_details' => $error_details, |
| | | 'user_id' => $this->userID, |
| | | 'request_time' => time(), |
| | | 'consecutive_errors' => $this->error_stats['consecutive_errors'], |
| | | 'is_oauth' => $this->isOAuthService, |
| | | 'api_version' => $this->apiVersion, |
| | | 'integration_healthy' => $this->is_healthy |
| | | ]; |
| | | |
| | | // Add rate limit information if present |
| | | if ($code === 429) { |
| | | $error_context['rate_limit'] = $this->extractRateLimitInfo($decoded, $body); |
| | | } |
| | | |
| | | // Log to ErrorHandler with proper severity |
| | | $this->logError($message, $error_context, $severity); |
| | | |
| | | // Update error statistics |
| | | $this->updateErrorStats($code, $endpoint); |
| | | |
| | | // Check if integration should be marked unhealthy |
| | | $this->checkIntegrationHealth(); |
| | | |
| | | // Store last error for debugging |
| | | $this->lastError = [ |
| | | 'code' => $code, |
| | | 'message' => $message, |
| | | 'endpoint' => $endpoint, |
| | | 'timestamp' => time(), |
| | | 'context' => $error_context |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Check if error is a client error (4xx) |
| | | */ |
| | | protected function isClientError(Exception $e): bool |
| | | { |
| | | $code = $e->getCode(); |
| | | return $code >= 400 && $code < 500; |
| | | } |
| | | |
| | | /** |
| | | * Get last error details |
| | | */ |
| | | public function getLastError(): array |
| | | { |
| | | return $this->lastError; |
| | | } |
| | | |
| | | |
| | | /***************************************************************** |
| | | OAUTH |
| | | *****************************************************************/ |
| | | /** |
| | | * Check user capabilities for specific actions |
| | | * Can be overridden by child classes for custom logic |
| | | */ |
| | | protected function checkCapabilities(string $action, ?int $userID = null): bool |
| | | { |
| | | // Default: require manage_options for admin actions |
| | | $admin_actions = ['disconnect', 'update_credentials', 'clear_cache']; |
| | | |
| | | if (in_array($action, $admin_actions)) { |
| | | return current_user_can('manage_options'); |
| | | } |
| | | |
| | | // User-specific actions |
| | | return is_user_logged_in(); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | OAUTH |
| | | *****************************************************************/ |
| | | protected function initializeOAuth():void |
| | | { |
| | | $this->oauth = array_merge([ |
| | | 'authorize' => '', |
| | | 'token' => '', |
| | | 'revoke' => '', |
| | | 'scopes' => [], |
| | | 'redirect_uri' => admin_url('admin-ajax.php?action=' . $this->service_name . '_oauth_callback') |
| | | ], $this->oauth); |
| | | } |
| | | |
| | | /** |
| | | * Get OAuth authorization URL |
| | | */ |
| | | public function getOAuthUrl(?string $return_url = null): string |
| | | { |
| | | |
| | | if (!$this->isOAuthService) { |
| | | return ''; |
| | | } |
| | | |
| | | if (empty($this->credentials)) { |
| | | $this->ensureInitialized(); |
| | | } |
| | | |
| | | if (empty($this->oauth['authorize'])) { |
| | | $this->logError('OAuth authorize URL not configured'); |
| | | return ''; |
| | | } |
| | | |
| | | // Build base parameters |
| | | $params = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? $this->credentials['app_id'] ?? '', |
| | | 'redirect_uri' => $this->getRedirectUri(), |
| | | 'response_type' => 'code', |
| | | 'scope' => implode(' ', $this->oauth['scopes'] ?? []) |
| | | ]; |
| | | $state_key = wp_generate_password(32, false); |
| | | $user_id = $this->userID??0; |
| | | |
| | | // Store state data in transient (expires in 10 minutes) |
| | | set_transient( |
| | | 'oauth_state_' . $state_key, |
| | | [ |
| | | 'service' => $this->service_name, |
| | | 'user_id' => $user_id, |
| | | 'created' => time() |
| | | ], |
| | | 600 |
| | | ); |
| | | |
| | | $state_parts = [ |
| | | $state_key, |
| | | $user_id |
| | | ]; |
| | | |
| | | // Add return URL if provided |
| | | if ($return_url) { |
| | | $state_parts[] = base64_encode($return_url); |
| | | } else { |
| | | $state_parts[] = base64_encode(admin_url('admin.php?page=jvb-integrations')); |
| | | } |
| | | |
| | | $params['state'] = implode('|', $state_parts); |
| | | |
| | | // Allow child classes to modify params (they can override/remove as needed) |
| | | if (method_exists($this, 'addOAuthParams')) { |
| | | $params = $this->addOAuthParams($params); |
| | | } |
| | | |
| | | $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; |
| | | } |
| | | |
| | | /** |
| | | * Add service-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | // Override in child classes to add service-specific params |
| | | // e.g., access_type, prompt, etc. |
| | | return $params; |
| | | } |
| | | |
| | | protected function makeOAuthRequest(string $method, string $url, array $data = []): array|WP_Error |
| | | { |
| | | // Check rate limits before making request |
| | | if (!$this->checkOAuthRateLimit()) { |
| | | // For OAuth, we might want to be more lenient with rate limits |
| | | // since these are critical authentication flows |
| | | $this->logError('OAuth request rate limited, waiting before retry'); |
| | | sleep(2); // Brief pause before critical OAuth request |
| | | |
| | | // Check again after wait |
| | | if (!$this->checkRateLimit()) { |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | | 'OAuth rate limit exceeded. Please try again in a few moments.' |
| | | ); |
| | | } |
| | | } |
| | | |
| | | // Record the request for rate limiting |
| | | $this->recordRequest($method, $url); |
| | | |
| | | // Build OAuth-specific headers |
| | | $headers = [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json', |
| | | ]; |
| | | |
| | | // Add API version for services that require it (like Square) |
| | | if (!empty($this->apiVersion)) { |
| | | $headers['Square-Version'] = $this->apiVersion; |
| | | } |
| | | |
| | | // Build request arguments |
| | | $args = [ |
| | | 'method' => $method, |
| | | 'headers' => $headers, |
| | | 'timeout' => 30, |
| | | 'sslverify' => true, |
| | | 'body' => json_encode($data) |
| | | ]; |
| | | |
| | | // Make the request with retry logic |
| | | $attempt = 0; |
| | | $max_attempts = 2; // Fewer retries for OAuth to avoid code expiry |
| | | |
| | | while ($attempt < $max_attempts) { |
| | | $attempt++; |
| | | |
| | | // Make the actual request |
| | | $response = wp_remote_request($url, $args); |
| | | |
| | | // Check for WordPress errors |
| | | if (is_wp_error($response)) { |
| | | if ($attempt >= $max_attempts) { |
| | | return $response; |
| | | } |
| | | |
| | | // Wait before retry |
| | | sleep($this->retryDelays[$attempt - 1] ?? 1); |
| | | continue; |
| | | } |
| | | |
| | | // Get response code and body |
| | | $code = wp_remote_retrieve_response_code($response); |
| | | $body = wp_remote_retrieve_body($response); |
| | | |
| | | // Parse JSON response |
| | | $data = json_decode($body, true); |
| | | |
| | | // Check for successful response |
| | | if ($code >= 200 && $code < 300) { |
| | | // Reset error stats on success |
| | | $this->resetErrorStats(); |
| | | |
| | | // Return parsed data |
| | | return $data ?: []; |
| | | } |
| | | |
| | | // Handle specific OAuth error codes |
| | | if ($code === 401) { |
| | | // Invalid credentials - don't retry |
| | | $this->handleApiError($code, $body, $url); |
| | | return new WP_Error('invalid_credentials', 'Invalid OAuth credentials'); |
| | | } |
| | | |
| | | if ($code === 429) { |
| | | // Rate limited - wait longer before retry |
| | | if ($attempt < $max_attempts) { |
| | | $retry_after = $this->extractRetryAfter($response); |
| | | sleep($retry_after ?: 5); |
| | | continue; |
| | | } |
| | | } |
| | | |
| | | // Check if we should retry |
| | | if ($attempt >= $max_attempts || $code >= 400 && $code < 500) { |
| | | // Client errors (4xx) typically shouldn't be retried |
| | | $this->handleApiError($code, $body, $url); |
| | | |
| | | $error_message = $data['error_description'] ?? |
| | | $data['error'] ?? |
| | | "OAuth request failed with status {$code}"; |
| | | |
| | | return new WP_Error('oauth_error', $error_message, ['code' => $code]); |
| | | } |
| | | |
| | | // Server error - retry after delay |
| | | sleep($this->retryDelays[$attempt - 1] ?? 1); |
| | | } |
| | | |
| | | // Shouldn't reach here, but handle it |
| | | return new WP_Error('oauth_error', 'OAuth request failed after retries'); |
| | | } |
| | | |
| | | /** |
| | | * Extract retry-after header from response |
| | | * |
| | | * @param array|WP_Error $response WordPress HTTP response |
| | | * @return int Seconds to wait before retry |
| | | */ |
| | | protected function extractRetryAfter($response): int |
| | | { |
| | | if (is_wp_error($response)) { |
| | | return 5; |
| | | } |
| | | |
| | | $headers = wp_remote_retrieve_headers($response); |
| | | |
| | | if (isset($headers['retry-after'])) { |
| | | // Could be seconds or HTTP date |
| | | $retry_after = $headers['retry-after']; |
| | | |
| | | if (is_numeric($retry_after)) { |
| | | return (int) $retry_after; |
| | | } |
| | | |
| | | // Try to parse as date |
| | | $timestamp = strtotime($retry_after); |
| | | if ($timestamp !== false) { |
| | | return max(0, $timestamp - time()); |
| | | } |
| | | } |
| | | |
| | | return 5; // Default wait time |
| | | } |
| | | |
| | | /** |
| | | * Exchange OAuth code for tokens |
| | | */ |
| | | protected function exchangeOAuthCode(string $code): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | |
| | | // Build request data |
| | | $request_data = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'code' => $code, |
| | | 'grant_type' => 'authorization_code', |
| | | 'redirect_uri' => $this->getRedirectUri() |
| | | ]; |
| | | |
| | | $oauth_endpoint = $this->oauth['token']; |
| | | $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('OAuth token exchange failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'code' => $response->get_error_code() |
| | | ]); |
| | | return null; |
| | | } |
| | | |
| | | // 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' => $expires_in, |
| | | 'expires_at' => time() + $expires_in, // Calculate expiry timestamp |
| | | 'token_type' => $response['token_type'] ?? 'Bearer', |
| | | 'merchant_id' => $response['merchant_id'] ?? '', |
| | | 'scope' => $response['scope'] ?? '' |
| | | ]; |
| | | } |
| | | |
| | | $this->logError('Failed to obtain access token', ['response' => $response]); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add service-specific credential data |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Override in child classes to add service-specific data |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | { |
| | | if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | // Build refresh request data |
| | | $request_data = [ |
| | | 'client_id' => $this->credentials['client_id'], |
| | | 'client_secret' => $this->credentials['client_secret'], |
| | | 'refresh_token' => $this->credentials['refresh_token'], |
| | | 'grant_type' => 'refresh_token' |
| | | ]; |
| | | |
| | | // Use centralized OAuth request method |
| | | $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to refresh Square token', [ |
| | | 'error' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | if (isset($response['access_token'])) { |
| | | $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 |
| | | if (isset($response['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $response['refresh_token']; |
| | | } |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Revoke OAuth access |
| | | */ |
| | | public function revokeOAuthAccess(): bool |
| | | { |
| | | if (!$this->isOAuthService || empty($this->oauth['revoke'])) { |
| | | return false; |
| | | } |
| | | |
| | | if (!empty($this->credentials['access_token'])) { |
| | | wp_remote_post($this->oauth['revoke'], [ |
| | | 'body' => ['token' => $this->credentials['access_token']] |
| | | ]); |
| | | } |
| | | |
| | | return CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID); |
| | | |
| | | } |
| | | |
| | | public function handleOAuthDisconnect(): array |
| | | { |
| | | try { |
| | | // Revoke the token with Square using centralized request |
| | | if (!empty($this->credentials['access_token'])) { |
| | | $revoke_data = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'access_token' => $this->credentials['access_token'] |
| | | ]; |
| | | |
| | | // Make revoke request (ignore response as revoke often returns empty) |
| | | $this->makeOAuthRequest('POST', $this->oauth['revoke'], $revoke_data); |
| | | } |
| | | |
| | | // Clear stored credentials (preserve app credentials) |
| | | $this->credentials = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'environment' => $this->credentials['environment'] ?? 'sandbox' |
| | | ]; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully disconnected from Square' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to disconnect: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Generate webhook signature key for services that require it |
| | | * @return string |
| | |
| | | { |
| | | return wp_generate_password(32, false); |
| | | } |
| | | /** |
| | | * Generate OAuth state parameter |
| | | */ |
| | | protected function generateOAuthState(?int $user_id, ?string $return_url = null): string |
| | | { |
| | | $user_id = $user_id ?? 0; |
| | | $state = wp_create_nonce($this->service_name . '_oauth_' . $user_id) . '|' . $user_id; |
| | | |
| | | if ($return_url) { |
| | | $state .= '|' . base64_encode($return_url); |
| | | } |
| | | |
| | | return $state; |
| | | } |
| | | |
| | | protected function getNonce(): string |
| | | { |
| | | return wp_create_nonce($this->service_name . '_oauth_' . $this->userID); |
| | | } |
| | | |
| | | /** |
| | | * Determine return URL after OAuth |
| | | */ |
| | | protected function determineReturnUrl(?int $user_id): string |
| | | { |
| | | if ($user_id > 0) { |
| | | return home_url('/dash/integrations/#' . $this->service_name); |
| | | } |
| | | |
| | | return admin_url('admin.php?page=jvb-integrations'); |
| | | } |
| | | |
| | | |
| | | /**************************************************************** |
| | | POST SYNC |
| | | ****************************************************************/ |
| | | /** |
| | | * Get field mapping for a post type |
| | | */ |
| | | protected function getFieldMapping(string $post_type): array |
| | | { |
| | | // Apply filter for custom mapping |
| | | return apply_filters( |
| | | "jvb_{$this->service_name}_field_mapping", |
| | | [], |
| | | $post_type, |
| | | $this |
| | | ); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Map WordPress fields to service fields |
| | | */ |
| | | protected function mapFieldsToService(int $postID, array $mapping): array |
| | | { |
| | | $meta_manager = new MetaManager($postID, 'post'); |
| | | $post = get_post($postID); |
| | | $service_data = []; |
| | | |
| | | foreach ($mapping as $wp_field => $service_field) { |
| | | $value = null; |
| | | |
| | | // Check if it's a post field |
| | | if (property_exists($post, $wp_field)) { |
| | | $value = $post->$wp_field; |
| | | } else { |
| | | // It's a meta field |
| | | $value = $meta_manager->getValue($wp_field); |
| | | } |
| | | |
| | | if ($value !== null && $value !== '') { |
| | | $this->setNestedValue($service_data, $service_field, $value); |
| | | } |
| | | } |
| | | |
| | | return apply_filters( |
| | | "jvb_{$this->service_name}_mapped_data", |
| | | $service_data, |
| | | $postID, |
| | | $mapping |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Set nested array value using dot notation |
| | | */ |
| | | protected function setNestedValue(array &$array, string $path, $value): void |
| | | { |
| | | $keys = explode('.', $path); |
| | | $current = &$array; |
| | | |
| | | foreach ($keys as $i => $key) { |
| | | if ($i === count($keys) - 1) { |
| | | $current[$key] = $value; |
| | | } else { |
| | | if (!isset($current[$key])) { |
| | | $current[$key] = []; |
| | | } |
| | | $current = &$current[$key]; |
| | | } |
| | | } |
| | | } |
| | | /** |
| | | * Handle post save |
| | | */ |
| | | public function handleSavePost(int $postID, WP_Post $post, bool $update): void |
| | | { |
| | | error_log('Testing For Save Post'); |
| | | if (jvbNoSaveIt($postID, $post)) { |
| | | error_log('Excluded by jvbNoSaveIt'); |
| | | return; |
| | | } |
| | | if (empty($this->syncPostTypes)) { |
| | | error_log('No Syncable post types'); |
| | | return; |
| | | } |
| | | |
| | | $config = JVB_CONTENT[jvbNoBase($post->post_type)]??null; |
| | | if (!$config) { |
| | | error_log('No Config set'); |
| | | return; |
| | | } |
| | | |
| | | $settings = $config['integrations'][$this->service_name]??null; |
| | | if (!$settings) { |
| | | error_log('No settings'); |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]); |
| | | error_log('Fields to check: '.print_r($fields, true)); |
| | | if (!$fields['share_to_'.$this->service_name]) { |
| | | return; |
| | | } |
| | | |
| | | $isShared = isset($fields["_{$this->service_name}_item_id"]); |
| | | if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) { |
| | | error_log('Do not keep synced, not syncing with '.$this->service_name); |
| | | return; |
| | | } |
| | | |
| | | if ($post->post_status !== 'publish' && !$isShared) { |
| | | error_log('Not published and not already shared'); |
| | | return; |
| | | } |
| | | error_log('Sending to integration for processing...'); |
| | | $this->handleTheSavePost($postID, $post, $update, $settings); |
| | | } |
| | | |
| | | |
| | | protected function getSyncFields(int $postID, string $type, array $additional = []):array |
| | | { |
| | | $meta = new MetaManager($postID, $type); |
| | | $fieldsToCheck = [ |
| | | 'share_to_' . $this->service_name, |
| | | '_keep_synced_' . $this->service_name, |
| | | "_{$this->service_name}_item_id", |
| | | "_{$this->service_name}_last_sync", |
| | | "_{$this->service_name}_shared_at", |
| | | "_{$this->service_name}_sync_status", |
| | | ... $additional |
| | | ]; |
| | | return $meta->getAll($fieldsToCheck); |
| | | } |
| | | |
| | | /** |
| | | * Handle post status transitions |
| | | */ |
| | | public function handlePostStatusTransition(string $new_status, string $old_status, WP_Post $post): void |
| | | { |
| | | if (empty($this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | if (!in_array(jvbNoBase($post->post_type), $this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | //Map fields from our custom post types to the fields expected by the integration |
| | | $mappedFields = $this->getFieldMapping($post->post_type); |
| | | |
| | | $fields = $this->getSyncFields($post->ID, 'post', $mappedFields); |
| | | |
| | | // Handle unpublish action |
| | | if ($old_status === 'publish' && $new_status !== 'publish') { |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] && $this->canSync['delete']) { |
| | | try { |
| | | $this->queueOperation( |
| | | 'delete_post', |
| | | [ |
| | | $post, |
| | | $fields["_{$this->service_name}_item_id"] |
| | | ] |
| | | ); |
| | | } catch (Exception $e) { |
| | | $this->logError("Failed to handle unpublish for post {$post->ID}: " . $e->getMessage()); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | if (!$this->canSync['delete']) { |
| | | return; |
| | | } |
| | | |
| | | $post = get_post($postID); |
| | | if (!$post || !in_array($post->post_type, $this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($postID, 'post'); |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | |
| | | $this->queueOperation( |
| | | 'delete_post', |
| | | [ |
| | | 'post_id' => $post->ID, |
| | | ] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | protected function handleSaveTerm($term_id, $tt_id, $taxonomy, $update, $args): void |
| | | { |
| | | $noBase = jvbNoBase($taxonomy); |
| | | if (!in_array($noBase, $this->syncTaxonomies)) { |
| | | return; |
| | | } |
| | | // Check if taxonomy is content-type |
| | | $config = JVB_TAXONOMY[$noBase] ?? null; |
| | | if (!$config || !($config['is_content'] ?? false)) { |
| | | return; |
| | | } |
| | | |
| | | $settings = $config['integrations'][$this->service_name] ?? null; |
| | | if (!$settings) { |
| | | return; |
| | | } |
| | | |
| | | // Similar sync logic as handleSavePost but for terms |
| | | $this->handleTheTermSave($term_id, $taxonomy, $update, $settings); |
| | | } |
| | | |
| | | protected function handleTheTermSave($term_id, $taxonomy, $update, $settings) { |
| | | |
| | | } |
| | | |
| | | protected function getTermFieldMapping(string $taxonomy): array |
| | | { |
| | | return apply_filters( |
| | | "jvb_{$this->service_name}_term_field_mapping", |
| | | [], |
| | | $taxonomy, |
| | | $this |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Share post to external service (override in child classes) |
| | | */ |
| | | protected function sharePost(WP_Post $post): ?string |
| | | { |
| | | throw new Exception('sharePost must be implemented by child class'); |
| | | } |
| | | |
| | | /** |
| | | * Update shared post on external service (override in child classes) |
| | | */ |
| | | protected function updateSharedPost(WP_Post $post, string $external_id): void |
| | | { |
| | | throw new Exception('updateSharedPost must be implemented by child class'); |
| | | } |
| | | |
| | | /** |
| | | * Remove post from external service (override in child classes) |
| | | */ |
| | | protected function unsharePost(WP_Post $post, string $external_id): void |
| | | { |
| | | throw new Exception('unsharePost must be implemented by child class'); |
| | | } |
| | | |
| | | /** |
| | | * Get sync capabilities |
| | | */ |
| | | public function getSyncCapabilities(): array |
| | | { |
| | | return $this->canSync; |
| | | } |
| | | |
| | | /** |
| | | * Check if a specific sync capability is supported |
| | | */ |
| | | public function supportsSyncCapability(string $capability): bool |
| | | { |
| | | return $this->canSync[$capability] ?? false; |
| | | } |
| | | |
| | | /** |
| | | * Get posts shared to this service |
| | | */ |
| | | public function getSharedPosts(array $args = []): array |
| | | { |
| | | $defaults = [ |
| | | 'post_type' => $this->syncPostTypes, |
| | | 'meta_query' => [ |
| | | [ |
| | | 'key' => BASE."_{$this->service_name}_item_id", |
| | | 'compare' => 'EXISTS' |
| | | ] |
| | | ], |
| | | 'posts_per_page' => -1 |
| | | ]; |
| | | |
| | | $args = wp_parse_args($args, $defaults); |
| | | return get_posts($args); |
| | | } |
| | | |
| | | /** |
| | | * Check if post is shared to this service |
| | | */ |
| | | public function isPostShared(int $postID): bool |
| | | { |
| | | $external_id = get_post_meta($postID, BASE."_{$this->service_name}_item_id", true); |
| | | return $external_id !== ''; |
| | | } |
| | | |
| | | /** |
| | | * Get external ID for a post |
| | | */ |
| | | public function getPostExternalId(int $postID): ?string |
| | | { |
| | | $external_id = get_post_meta($postID, BASE."_{$this->service_name}_item_id", true); |
| | | return $external_id ?: null; |
| | | } |
| | | |
| | | /******************************************************************* |
| | | * UTILITIES |
| | | *******************************************************************/ |
| | | |
| | | /** |
| | | * Get API URL for endpoint |
| | | */ |
| | | protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false |
| | | { |
| | | if ($this->isOAuthService && in_array($endpoint, $this->oauth)) { |
| | | return $endpoint; |
| | | } |
| | | if (is_array($this->apiBase)) { |
| | | if ($baseKey && array_key_exists($baseKey, $this->apiBase)) { |
| | | $base = $this->apiBase[$baseKey]; |
| | | } else { |
| | | $base = ($this->apiBase['base'] ?? reset($this->apiBase)); |
| | | } |
| | | } else { |
| | | $base = $this->apiBase; |
| | | } |
| | | |
| | | if (!$base || $base === '') { |
| | | $this->logError('API base URL not configured for {$this->>service_name}'); |
| | | return false; |
| | | } |
| | | |
| | | // Handle named endpoints |
| | | if (!$this->isValidEndpoint($endpoint)) { |
| | | $this->logError("{$endpoint} is not a valid endpoint for {$this->service_name}"); |
| | | return false; |
| | | } |
| | | |
| | | // Build full URL |
| | | $base = rtrim($base, '/'); |
| | | $endpoint = ltrim($endpoint, '/'); |
| | | |
| | | return "{$base}/{$endpoint}"; |
| | | } |
| | | |
| | | /** |
| | | * Check if an endpoint is valid, supporting both exact matches and patterns |
| | | * |
| | | * @param string $endpoint |
| | | * @return bool |
| | | */ |
| | | protected function isValidEndpoint(string $endpoint): bool |
| | | { |
| | | // Remove query parameters for validation |
| | | $endpointPath = parse_url($endpoint, PHP_URL_PATH); |
| | | if ($endpointPath === false) { |
| | | $endpointPath = $endpoint; |
| | | } |
| | | |
| | | foreach ($this->apiEndpoints as $pattern) { |
| | | // Check for exact match first |
| | | if ($endpointPath === $pattern) { |
| | | return true; |
| | | } |
| | | |
| | | if (str_starts_with($endpointPath, $pattern)) { |
| | | return true; |
| | | } |
| | | |
| | | // Check if pattern contains wildcards (indicated by square brackets) |
| | | if (strpos($pattern, '[') !== false) { |
| | | // Convert the pattern to a regex |
| | | $regexPattern = '#^' . str_replace(['[^/]+'], ['[^/]+'], $pattern) . '(?:\?.*)?$#'; |
| | | if (preg_match($regexPattern, $endpoint)) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Build cache key |
| | | */ |
| | | protected function buildCacheKey(string $method, string $endpoint, array $params = []): string |
| | | { |
| | | $key_parts = [ |
| | | $this->service_name, |
| | | $method, |
| | | $endpoint |
| | | ]; |
| | | |
| | | if ($this->userID) { |
| | | $key_parts[] = "user_{$this->userID}"; |
| | | } |
| | | |
| | | if (!empty($params)) { |
| | | $key_parts[] = md5(serialize($params)); |
| | | } |
| | | |
| | | return implode('_', $key_parts); |
| | | } |
| | | |
| | | /** |
| | | * Log error message |
| | | */ |
| | | protected function logError(string $message, array $context = [], ?string $severity = null): void |
| | | { |
| | | if (!$this->errorHandler) { |
| | | $this->errorHandler = JVB()->error(); |
| | | } |
| | | // Determine severity if not provided |
| | | if ($severity === null) { |
| | | $severity = 'error'; |
| | | } |
| | | |
| | | // Add integration-specific context |
| | | $context = array_merge($context, [ |
| | | 'component' => 'Integration:' . $this->service_name, |
| | | 'user_id' => $this->userID, |
| | | 'service' => $this->service_name, |
| | | 'timestamp' => current_time('mysql'), |
| | | 'memory_usage' => memory_get_usage(true), |
| | | 'peak_memory' => memory_get_peak_usage(true), |
| | | 'integration_health' => [ |
| | | 'is_healthy' => $this->is_healthy, |
| | | 'consecutive_errors' => $this->error_stats['consecutive_errors'], |
| | | 'total_errors' => $this->error_stats['total_errors'], |
| | | 'last_success' => $this->error_stats['last_success'] |
| | | ] |
| | | ]); |
| | | |
| | | // Log through ErrorHandler |
| | | $this->errorHandler->log( |
| | | 'Integration:' . $this->service_name, |
| | | $message, |
| | | $context, |
| | | $severity |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Categorize API errors for better tracking |
| | | */ |
| | | protected function categorizeApiError(int $code): string |
| | | { |
| | | return match(true) { |
| | | $code >= 400 && $code < 404 => 'client_error', |
| | | $code === 404 => 'not_found', |
| | | $code === 401 => 'authentication', |
| | | $code === 403 => 'authorization', |
| | | $code === 429 => 'rate_limit', |
| | | $code >= 500 && $code < 600 => 'server_error', |
| | | default => 'unknown' |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Determine error severity based on HTTP code |
| | | */ |
| | | protected function getErrorSeverity(int $code): string |
| | | { |
| | | return match(true) { |
| | | $code === 429 => 'warning', // Rate limiting |
| | | $code >= 400 && $code < 500 => 'error', // Client errors |
| | | $code >= 500 => 'critical', // Server errors |
| | | default => 'error' |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Extract error details from response |
| | | */ |
| | | protected function extractErrorDetails($decoded, string $body): array |
| | | { |
| | | $details = [ |
| | | 'message' => 'Unknown error', |
| | | 'code' => null, |
| | | 'details' => null |
| | | ]; |
| | | |
| | | if ($decoded && isset($decoded['error'])) { |
| | | if (is_array($decoded['error'])) { |
| | | $details['message'] = $decoded['error']['message'] ?? json_encode($decoded['error']); |
| | | $details['code'] = $decoded['error']['code'] ?? null; |
| | | $details['details'] = $decoded['error']['details'] ?? null; |
| | | } else { |
| | | $details['message'] = $decoded['error']; |
| | | } |
| | | } elseif ($decoded && isset($decoded['message'])) { |
| | | $details['message'] = $decoded['message']; |
| | | } elseif (!empty($body)) { |
| | | $details['message'] = $body; |
| | | } |
| | | |
| | | return $details; |
| | | } |
| | | |
| | | /** |
| | | * Extract rate limit information from response |
| | | */ |
| | | protected function extractRateLimitInfo($decoded, string $body): array |
| | | { |
| | | $info = [ |
| | | 'retry_after' => null, |
| | | 'limit' => null, |
| | | 'remaining' => null, |
| | | 'reset' => null |
| | | ]; |
| | | |
| | | // Try to extract from decoded response |
| | | if ($decoded) { |
| | | $info['retry_after'] = $decoded['retry_after'] ?? $decoded['retry-after'] ?? null; |
| | | $info['limit'] = $decoded['x-rate-limit-limit'] ?? null; |
| | | $info['remaining'] = $decoded['x-rate-limit-remaining'] ?? null; |
| | | $info['reset'] = $decoded['x-rate-limit-reset'] ?? null; |
| | | } |
| | | |
| | | return $info; |
| | | } |
| | | |
| | | /** |
| | | * Update error statistics |
| | | */ |
| | | protected function updateErrorStats(int $code, string $endpoint): void |
| | | { |
| | | $this->error_stats['total_errors']++; |
| | | $this->error_stats['consecutive_errors']++; |
| | | |
| | | // Track error types |
| | | $error_type = $this->categorizeApiError($code); |
| | | if (!isset($this->error_stats['error_types'][$error_type])) { |
| | | $this->error_stats['error_types'][$error_type] = 0; |
| | | } |
| | | $this->error_stats['error_types'][$error_type]++; |
| | | |
| | | // Save stats to cache |
| | | $this->saveErrorStats(); |
| | | } |
| | | |
| | | /** |
| | | * Reset error statistics on successful request |
| | | */ |
| | | protected function resetErrorStats(): void |
| | | { |
| | | $this->error_stats['consecutive_errors'] = 0; |
| | | $this->error_stats['last_success'] = time(); |
| | | $this->is_healthy = true; |
| | | |
| | | $this->saveErrorStats(); |
| | | } |
| | | |
| | | /** |
| | | * Get integration health status |
| | | */ |
| | | public function getHealthStatus(): array |
| | | { |
| | | return [ |
| | | 'is_healthy' => $this->is_healthy, |
| | | 'consecutive_errors' => $this->error_stats['consecutive_errors'], |
| | | 'total_errors' => $this->error_stats['total_errors'], |
| | | 'last_success' => $this->error_stats['last_success'], |
| | | 'error_types' => $this->error_stats['error_types'], |
| | | 'threshold' => $this->error_threshold |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Manually reset integration health |
| | | */ |
| | | public function resetHealth(): void |
| | | { |
| | | $this->error_stats = [ |
| | | 'total_errors' => 0, |
| | | 'consecutive_errors' => 0, |
| | | 'last_success' => time(), |
| | | 'error_types' => [] |
| | | ]; |
| | | $this->is_healthy = true; |
| | | $this->saveErrorStats(); |
| | | |
| | | $this->logDebug('Integration health manually reset', [ |
| | | 'reset_by' => get_current_user_id(), |
| | | 'reset_time' => time() |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Check integration health based on error patterns |
| | | */ |
| | | protected function checkIntegrationHealth(): void |
| | | { |
| | | // Mark unhealthy if too many consecutive errors |
| | | if ($this->error_stats['consecutive_errors'] >= $this->error_threshold) { |
| | | $this->is_healthy = false; |
| | | |
| | | // Log critical health issue |
| | | $this->logError( |
| | | "Integration marked unhealthy after {$this->error_stats['consecutive_errors']} consecutive errors", |
| | | [ |
| | | 'error_stats' => $this->error_stats, |
| | | 'threshold' => $this->error_threshold |
| | | ], |
| | | 'critical' |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Load error statistics from cache |
| | | */ |
| | | protected function loadErrorStats(): void |
| | | { |
| | | $cache_key = "error_stats_{$this->service_name}_{$this->userID}"; |
| | | $cached_stats = $this->cache->get($cache_key); |
| | | |
| | | if ($cached_stats) { |
| | | $this->error_stats = array_merge($this->error_stats, $cached_stats); |
| | | |
| | | // Check if integration was marked unhealthy |
| | | if ($this->error_stats['consecutive_errors'] >= $this->error_threshold) { |
| | | $this->is_healthy = false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Save error statistics to cache |
| | | */ |
| | | protected function saveErrorStats(): void |
| | | { |
| | | $cache_key = "error_stats_{$this->service_name}_{$this->userID}"; |
| | | $this->cache->set($cache_key, $this->error_stats, 86400); // 24 hours |
| | | } |
| | | |
| | | protected function logDebug(string $message, array $context = []): void |
| | | { |
| | | error_log('[Integration: '.$this->service_name.']'.$message.': '.print_r($context, true)); |
| | | } |
| | | |
| | | /** |
| | | * Get service name |
| | | */ |
| | | public function getServiceName(): string |
| | |
| | | return $this->title; |
| | | } |
| | | |
| | | /********************************************************************* |
| | | RENDERING |
| | | *********************************************************************/ |
| | | // public function renderAdditionalOptions() |
| | | // { |
| | | // //Default: nothing. |
| | | // } |
| | | public static function title():string |
| | | { |
| | | return static::getInstance()->getTitle(); |
| | | } |
| | | public static function icon():string |
| | | { |
| | | return static::getInstance()->getIcon(); |
| | | } |
| | | |
| | | /** |
| | | * Render additional action buttons (optional) |
| | | * Override in integration classes that need extra actions |
| | | */ |
| | | // public function renderAdditionalActions(): void |
| | | // { |
| | | // // Default: no additional actions |
| | | // // Override in extensions for service-specific actions |
| | | // } |
| | | public static function hasExtraOptions():bool |
| | | { |
| | | return static::getInstance()::$hasExtraOptions; |
| | | } |
| | | |
| | | /** |
| | | * Get service description (optional) |
| | |
| | | return "Manage your {$this->getServiceName()} integration settings."; |
| | | } |
| | | |
| | | /** |
| | | * Get service key for consistency |
| | | */ |
| | | public function getServiceKey(): string |
| | | { |
| | | return strtolower($this->service_name); |
| | | } |
| | | |
| | | /** |
| | | * Refresh credentials from storage and re-initialize |
| | | */ |
| | | public function refreshCredentials(): void |
| | | { |
| | | $this->credentials = []; |
| | | $this->ensureInitialized(); |
| | | } |
| | | |
| | | /** |
| | | * Enhanced webhook handling with common patterns |
| | | */ |
| | | public function handleWebhook(array $payload): bool |
| | | { |
| | | // Log incoming webhook for debugging |
| | | $this->logDebug('Webhook received', [ |
| | | 'payload_keys' => array_keys($payload), |
| | | 'timestamp' => time() |
| | | ]); |
| | | |
| | | // Validate webhook signature/authenticity |
| | | if (!$this->validateWebhook($payload)) { |
| | | $this->logError('Webhook validation failed', [ |
| | | 'webhook_type' => 'validation_error', |
| | | 'payload_sample' => array_slice($payload, 0, 5) // Log partial payload for debugging |
| | | ], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | // Check for duplicate processing (idempotency) |
| | | if ($this->isWebhookProcessed($payload)) { |
| | | $this->logDebug('Webhook already processed', [ |
| | | 'webhook_id' => $this->extractWebhookId($payload) |
| | | ]); |
| | | return true; // Return true to prevent retries |
| | | } |
| | | |
| | | try { |
| | | // Process the webhook |
| | | $result = $this->processWebhook($payload); |
| | | |
| | | // Mark as processed if successful |
| | | if ($result) { |
| | | $this->markWebhookProcessed($payload); |
| | | $this->resetErrorStats(); |
| | | } else { |
| | | $this->logError('Webhook processing returned false', [ |
| | | 'webhook_id' => $this->extractWebhookId($payload) |
| | | ], 'warning'); |
| | | } |
| | | |
| | | return $result; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Webhook processing failed', [ |
| | | 'error' => $e->getMessage(), |
| | | 'webhook_id' => $this->extractWebhookId($payload), |
| | | 'trace' => $e->getTraceAsString() |
| | | ], 'critical'); |
| | | |
| | | // Update error stats |
| | | $this->updateErrorStats($e->getCode() ?: 500, 'webhook'); |
| | | $this->checkIntegrationHealth(); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Validate webhook signature |
| | |
| | | return hash_equals($expected, $signature); |
| | | } |
| | | |
| | | protected function validateWebhook(array $payload):bool |
| | | { |
| | | //Handled by child classes |
| | | return false; |
| | | } |
| | | protected function processWebhook(array $payload):bool |
| | | { |
| | | //Handled by child classes |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Check if webhook was already processed (prevent duplicates) |
| | | */ |
| | | protected function isWebhookProcessed(array $payload): bool |
| | | { |
| | | $webhook_id = $this->extractWebhookId($payload); |
| | | if (!$webhook_id) { |
| | | return false; |
| | | } |
| | | |
| | | $cache_key = "webhook_processed_{$this->service_name}_{$webhook_id}"; |
| | | return $this->cache->get($cache_key) !== null; |
| | | } |
| | | |
| | | /** |
| | | * Mark webhook as processed |
| | | */ |
| | | protected function markWebhookProcessed(array $payload): void |
| | | { |
| | | $webhook_id = $this->extractWebhookId($payload); |
| | | if ($webhook_id) { |
| | | $cache_key = "webhook_processed_{$this->service_name}_{$webhook_id}"; |
| | | $this->cache->set($cache_key, time(), 86400); // Store for 24 hours |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Extract unique webhook ID (override in child classes) |
| | | */ |
| | | protected function extractWebhookId(array $payload): ?string |
| | | { |
| | | // Common patterns - child classes can override |
| | | return $payload['id'] ?? |
| | | $payload['event_id'] ?? |
| | | $payload['webhook_id'] ?? |
| | | md5(serialize($payload)); |
| | | } |
| | | |
| | | /** |
| | | * Register webhook endpoint |
| | | */ |
| | | protected function registerWebhookEndpoint(): void |
| | | { |
| | | // Register REST API endpoint for webhooks |
| | | add_action('rest_api_init', function() { |
| | | register_rest_route('jvb/v1', '/webhooks/' . $this->service_name, [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleWebhookRequest'], |
| | | 'permission_callback' => '__return_true', // Webhooks come from external services |
| | | ]); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Handle webhook REST API request |
| | | */ |
| | | public function handleWebhookRequest(WP_REST_Request $request): WP_REST_Response |
| | | { |
| | | $payload = $request->get_params(); |
| | | $headers = $request->get_headers(); |
| | | |
| | | // Include headers in payload for signature validation |
| | | $payload['_headers'] = $headers; |
| | | |
| | | $success = $this->handleWebhook($payload); |
| | | |
| | | return new WP_REST_Response([ |
| | | 'success' => $success |
| | | ], $success ? 200 : 400); |
| | | } |
| | | |
| | | protected function renderWebhookUrl(): string |
| | | { |
| | | if (!$this->handleWebhooks || !$this->isSetUp()) { |
| | | return ''; |
| | | } |
| | | |
| | | $webhook_url = rest_url('jvb/v1/webhooks/' . $this->service_name); |
| | | |
| | | return sprintf( |
| | | '<div class="webhook-info"> |
| | | <h4>Webhook URL</h4> |
| | | <code id="webhook-url-%s">%s</code> |
| | | <p class="hint">Add this URL to your %s webhook settings</p> |
| | | </div>', |
| | | esc_attr($this->service_name), |
| | | esc_html($webhook_url), |
| | | esc_html($this->title) |
| | | ); |
| | | } |
| | | |
| | | public function renderConnection(bool $return = false):string |
| | | { |
| | | if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) { |
| | | return ''; |
| | | } |
| | | |
| | | $meta = new MetaManager($this->userID, 'integrations'); |
| | | $is_connected = $this->isSetUp(); |
| | | $credentials = $this->getCredentials(); |
| | | |
| | | $admin_only = $this->isOAuthService ? [ |
| | | 'client_id', |
| | | 'client_secret', |
| | | ] : []; |
| | | |
| | | ob_start(); |
| | | ?> |
| | | <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>" |
| | | data-service="<?php echo esc_attr($this->service_name); ?>"> |
| | | <div class="header row btw"> |
| | | <h3><?php echo esc_html($this->title); ?></h3> |
| | | <div class="setup"> |
| | | <?php if ($is_connected): ?> |
| | | <span class="indicator connected">●</span> |
| | | <span class="text">Set Up</span> |
| | | <?php else: ?> |
| | | <span class="indicator disconnected">●</span> |
| | | <span class="text">Not Set Up</span> |
| | | <?php endif; ?> |
| | | </div> |
| | | </div> |
| | | |
| | | <?php if ($is_connected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0): ?> |
| | | <div class="meta"> |
| | | <small>Last updated: <?php echo human_time_diff($credentials['updated_at']) . ' ago'; ?></small> |
| | | </div> |
| | | <?php endif; ?> |
| | | |
| | | <?php |
| | | if (!empty($this->instructions)) { |
| | | ?> |
| | | <details> |
| | | <summary> |
| | | Instructions |
| | | </summary> |
| | | <ol> |
| | | <?php |
| | | foreach ($this->instructions as $instruction) { |
| | | echo '<li>'.$instruction.'</li>'; |
| | | } |
| | | ?> |
| | | </ol> |
| | | </details> |
| | | <?php |
| | | } |
| | | ?> |
| | | <details class="initial-setup"<?= $is_connected?'' : ' open'?>> |
| | | <summary>Initial Setup</summary> |
| | | <?php |
| | | foreach ($this->fields as $name => $config) { |
| | | if ($is_connected && !empty($credentials[$name])) { |
| | | if (in_array($name, $admin_only) && !current_user_can('manage_options')) { |
| | | continue; |
| | | } |
| | | ?> |
| | | <span class="label"><?=$config['label']?>:</span> |
| | | <code> |
| | | <?php |
| | | if (str_contains($name, 'secret')) { |
| | | for ($i = 1; $i<=strlen($credentials[$name]) - 8; $i++) { |
| | | echo '*'; |
| | | } |
| | | echo substr($credentials[$name], -8); |
| | | } else { |
| | | echo $credentials[$name]; |
| | | } |
| | | ?> |
| | | </code> |
| | | <?php |
| | | } else { |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['autocomplete'] = 'off'; |
| | | $config['base'] = $this->service_name.'_'; |
| | | $meta->render('form', $name, $config); |
| | | } |
| | | } |
| | | if ($this->handleWebhooks) { |
| | | echo $this->renderWebhookUrl(); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | |
| | | if ($this->isOAuthService) { |
| | | $this->renderConnectedOAuthStatus(); |
| | | } |
| | | |
| | | ?> |
| | | |
| | | <div class="integration-content"> |
| | | |
| | | <?php |
| | | |
| | | |
| | | |
| | | if (!empty($this->advanced)) { |
| | | ?> |
| | | <details> |
| | | <summary>Advanced Settings</summary> |
| | | <?php |
| | | foreach ($this->advanced as $name => $config) { |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | $meta->render('form', $name, $config); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | if (!empty($this->defaults)) { |
| | | ?> |
| | | <a href="<?php echo admin_url('admin.php?page=jvb-integration-' . $this->service_name); ?>" |
| | | class="button"> |
| | | More Settings |
| | | </a> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | <div class="actions row btw wrap"> |
| | | <?php |
| | | foreach ($this->buttons as $action => $label) { |
| | | if (!$is_connected && $action !== 'save_credentials') { |
| | | continue; |
| | | } |
| | | $title = $confirm = ''; |
| | | switch ($action) { |
| | | case 'save_credentials': |
| | | $title = $label; |
| | | $label = jvbIcon('floppy-disk'); |
| | | break; |
| | | case 'clear_credentials': |
| | | $title = $label; |
| | | $label = jvbIcon('plugs'); |
| | | $confirm = ' data-confirm="Are you sure you want to delete these credentials?"'; |
| | | break; |
| | | case 'clear_cache': |
| | | $title = $label; |
| | | $label = jvbIcon('arrows-clockwise'); |
| | | break; |
| | | } |
| | | $title = $title === '' ? '' : ' title ="'.$title.'"'; |
| | | ?> |
| | | <button type="button" data-action="<?=$action?>"<?=$title?><?=$confirm?>><?=$label?></button> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | </form> |
| | | <?php |
| | | $result = ob_get_clean(); |
| | | if(!$return) { |
| | | echo $result; |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | protected function renderConnectedOAuthStatus(): void |
| | | { |
| | | if (!$this->isSetup()) { |
| | | return; |
| | | } |
| | | $credentials = $this->getCredentials(); |
| | | $hasCredentials = $this->hasOAuthCredentials(); |
| | | $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url()); |
| | | ?> |
| | | |
| | | <details <?= $hasCredentials?' open':''?>> |
| | | <summary> |
| | | <?php if ($hasCredentials) { ?> |
| | | Connected Account |
| | | <?php } else { ?> |
| | | <div class="oauth-connect"> |
| | | <a href="<?php echo esc_url($this->getOAuthUrl($returnURL)); ?>" |
| | | class="button button-primary jvb-oauth-connect" |
| | | data-service="<?php echo esc_attr($this->service_name); ?>"> |
| | | <?php echo jvbIcon($this->icon); ?> |
| | | Authorize Connection |
| | | </a> |
| | | </div> |
| | | <?php } ?> |
| | | <div class="connection-status <?= $hasCredentials ? 'connected' : 'disconnected' ?>"> |
| | | <span class="status-indicator">●</span> |
| | | <span><?= $hasCredentials ? 'Connected' : 'Not Connected' ?></span> |
| | | </div> |
| | | </summary> |
| | | <label>OAuth Redirect URL:</label> |
| | | <code> |
| | | <?= $this->getRedirectUri(); ?> |
| | | </code> |
| | | |
| | | |
| | | <?php if (!empty($credentials['updated_at'])): ?> |
| | | <div class="oauth-meta"> |
| | | <small>Token expires: <?php |
| | | echo isset($credentials['expires_at']) |
| | | ? human_time_diff($credentials['expires_at']) |
| | | : 'Never'; |
| | | ?></small> |
| | | </div> |
| | | <?php endif; |
| | | // Allow child classes to add service-specific connected UI |
| | | $this->renderOAuthConnectedOptions(); |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | |
| | | protected function renderOAuthConnectedOptions():void |
| | | { |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Get last tested timestamp - extends your existing pattern |
| | | */ |
| | | protected function getLastTestedTime(): ?int |
| | | { |
| | | $key = $this->userID |
| | | ? "jvb_integration_{$this->service_name}_last_test_user_{$this->userID}" |
| | | : "jvb_integration_{$this->service_name}_last_test"; |
| | | return get_option($key, null); |
| | | } |
| | | |
| | | /** |
| | | * Update last tested time |
| | | */ |
| | | public function updateLastTestedTime(): void |
| | | { |
| | | $key = $this->userID |
| | | ? "jvb_integration_{$this->service_name}_last_test_user_{$this->userID}" |
| | | : "jvb_integration_{$this->service_name}_last_test"; |
| | | update_option($key, time()); |
| | | } |
| | | |
| | | public function handleAdminPost(): void |
| | | { |
| | | if (!current_user_can('manage_options')) { |
| | | wp_die('Insufficient permissions'); |
| | | } |
| | | |
| | | $service = $this->getServiceName(); |
| | | |
| | | // Verify nonce |
| | | $nonce_field = 'jvb_integration_nonce_' . $service; |
| | | $nonce_action = 'jvb_integration_save_' . $service; |
| | | error_log('handleAdminPost: '.print_r($_POST, true)); |
| | | if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) { |
| | | wp_die('Security check failed'); |
| | | } |
| | | |
| | | // Get the action type |
| | | $action_type = sanitize_text_field($_POST['action_type'] ?? 'save'); |
| | | |
| | | // Prepare the request in the format handleAjaxRequest expects |
| | | $_REQUEST['action'] = $action_type; |
| | | $_REQUEST['service'] = $service; |
| | | |
| | | // Copy all POST data to REQUEST |
| | | foreach ($_POST as $key => $value) { |
| | | $_REQUEST[$key] = $value; |
| | | } |
| | | |
| | | // Set up for JSON response capture |
| | | ob_start(); |
| | | |
| | | try { |
| | | // Call the existing AJAX handler |
| | | $this->handleAjaxRequest(); |
| | | $response = ob_get_clean(); |
| | | |
| | | // Parse the JSON response |
| | | $result = json_decode($response, true); |
| | | |
| | | if ($result && isset($result['success'])) { |
| | | if ($result['success']) { |
| | | $message = $result['message'] ?? ucfirst($service) . ' settings saved successfully!'; |
| | | $this->setAdminNotice($message, 'success'); |
| | | } else { |
| | | $message = $result['message'] ?? 'Failed to save ' . ucfirst($service) . ' settings.'; |
| | | $this->setAdminNotice($message, 'error'); |
| | | } |
| | | } else { |
| | | // If no proper JSON response, check if connection worked |
| | | if ($action_type === 'test') { |
| | | $connected = $this->testConnection(); |
| | | $message = $connected ? 'Connection successful!' : 'Connection failed. Please check your credentials.'; |
| | | $this->setAdminNotice($message, $connected ? 'success' : 'error'); |
| | | } |
| | | } |
| | | } catch (Exception $e) { |
| | | ob_end_clean(); |
| | | $this->setAdminNotice('Error: ' . $e->getMessage(), 'error'); |
| | | } |
| | | |
| | | // Redirect back to integrations page |
| | | wp_redirect(admin_url('admin.php?page=jvb-integrations')); |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Set admin notice using transients for redirect |
| | | */ |
| | | protected function setAdminNotice(string $message, string $type = 'info'): void |
| | | { |
| | | $notices = get_transient('jvb_admin_notices') ?: []; |
| | | $notices[] = [ |
| | | 'message' => $message, |
| | | 'type' => $type === 'success' ? 'updated' : 'error' |
| | | ]; |
| | | set_transient('jvb_admin_notices', $notices, 30); |
| | | } |
| | | |
| | | /** |
| | | * Display admin notices from transient |
| | | */ |
| | | public static function displayAdminNotices(): void |
| | | { |
| | | $notices = get_transient('jvb_admin_notices'); |
| | | |
| | | if ($notices) { |
| | | foreach ($notices as $notice) { |
| | | ?> |
| | | <div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible"> |
| | | <p><?php echo esc_html($notice['message']); ?></p> |
| | | </div> |
| | | <?php |
| | | } |
| | | delete_transient('jvb_admin_notices'); |
| | | } |
| | | } |
| | | |
| | | public function hasDefaults():bool |
| | | { |
| | | return !empty($this->defaults); |
| | | } |
| | | |
| | | public function renderDefaults():void |
| | | { |
| | | $types = $this->enabledContentTypes(); |
| | | if (empty($types)) { |
| | | return; |
| | | } |
| | | $meta = new MetaManager($this->userID, 'integrations'); |
| | | ?> |
| | | <form> |
| | | <h1><?= $this->title?> Defaults:</h1> |
| | | <p>Find yourself constantly repeating yourself?</p> |
| | | <p>Set defaults for different content types and <?=$this->title?>!</p> |
| | | <?php |
| | | foreach ($this->defaults as $name => $config) { |
| | | $config['required'] = false; |
| | | |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | $meta->render('form', $name, $config); |
| | | } |
| | | foreach ($this->syncPostTypes as $type) { |
| | | $config = JVB_CONTENT[$type]; |
| | | ?> |
| | | <details> |
| | | <summary><?= jvbIcon($config['icon']) ?><?= $config['singular']?> Defaults</summary> |
| | | <?php |
| | | $fields = new \JVBase\registry\providers\IntegrationFieldProvider(); |
| | | $fields = $fields->getIntegrationFields($this->service_name, $config); |
| | | foreach($fields as $name=>$c) { |
| | | $c['required'] = false; |
| | | if ($c['type'] === 'number') { |
| | | $c['type'] = 'text'; |
| | | $c['subtype'] = 'number'; |
| | | } |
| | | if (array_key_exists('description', $c)) { |
| | | $c['hint'] = $c['description']; |
| | | unset($c['description']); |
| | | } |
| | | $meta->render('form', $name, $c); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | ?> |
| | | </form> |
| | | <?php |
| | | } |
| | | |
| | | public function hasContent():bool |
| | | { |
| | | return $this->has_content; |
| | | } |
| | | public function getDefaultContentType():string |
| | | { |
| | | return $this->defaultContent; |
| | | } |
| | | |
| | | public function enabledContentTypes():array |
| | | { |
| | | if (!$this->has_content) { |
| | | return []; |
| | | } |
| | | |
| | | $key = BASE.$this->service_name.'_enabled_content_types'; |
| | | $enabled = get_option($key); |
| | | if (!$enabled) { |
| | | $enabled = []; |
| | | foreach (array_merge(JVB_CONTENT, JVB_TAXONOMY) as $content => $config) { |
| | | if (!array_key_exists('integrations', $config)) { |
| | | continue; |
| | | } |
| | | if (!array_key_exists($this->service_name, $config['integrations'])) { |
| | | continue; |
| | | } |
| | | if (!array_key_exists('content_type', $config['integrations'][$this->service_name])) { |
| | | continue; |
| | | } |
| | | $type = $config['integrations'][$this->service_name]['content_type']; |
| | | if (!in_array($type, $enabled)) { |
| | | $enabled[] = $type; |
| | | } |
| | | } |
| | | update_option($key, $enabled); |
| | | } |
| | | return $enabled; |
| | | $cred = Auth::getInstance(); |
| | | $cred->updateTested($this->service_name, $this->userID); |
| | | } |
| | | |
| | | protected function getSupportedImage(int $imgID):int |
| | |
| | | if ($jpegVersion !== '' && is_int($jpegVersion)) { |
| | | return $jpegVersion; |
| | | } |
| | | |
| | | $converted = $this->convertWebpToJpeg($imgID); |
| | | if ($converted) { |
| | | update_post_meta($imgID, BASE . 'jpeg_version', $converted); |
| | | return $converted; |
| | | $uploader = new UploadManager(); |
| | | $converted = $uploader->convertImageTo($imgID, 'jpeg', 80, false); |
| | | if ($converted && !is_wp_error($converted)) { |
| | | update_post_meta($imgID, BASE . 'jpeg_version', $converted['attachment_id']); |
| | | return $converted['attachment_id']; |
| | | } |
| | | |
| | | return $imgID; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Convert WebP image to JPEG |
| | | * |
| | | * @param int $webpAttachmentId |
| | | * @return int|false Image ID on success, false on failure. |
| | | * Used by JVBase\registrar\helpers\AddIntegrationFields.php |
| | | * @return array |
| | | */ |
| | | protected function convertWebpToJpeg(int $webpAttachmentId):int|false |
| | | public function getAdditionalFields(?string $content_type = null):array |
| | | { |
| | | try { |
| | | $webpPath = get_attached_file($webpAttachmentId); |
| | | |
| | | if (!file_exists($webpPath)) { |
| | | throw new Exception('WebP file not found'); |
| | | } |
| | | |
| | | // Generate JPEG filename |
| | | $pathInfo = pathinfo($webpPath); |
| | | $jpegPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '_jpeg.jpg'; |
| | | |
| | | // Check if already exists (shouldn't happen due to meta check, but safety first) |
| | | if (file_exists($jpegPath)) { |
| | | // Try to find existing attachment |
| | | global $wpdb; |
| | | $existingId = $wpdb->get_var($wpdb->prepare( |
| | | "SELECT ID FROM {$wpdb->posts} WHERE guid = %s", |
| | | str_replace(ABSPATH, site_url('/'), $jpegPath) |
| | | )); |
| | | |
| | | if ($existingId) { |
| | | return (int)$existingId; |
| | | } |
| | | } |
| | | |
| | | // Perform conversion based on available libraries |
| | | if (extension_loaded('imagick')) { |
| | | $this->convertWebpToJpegImagick($webpPath, $jpegPath); |
| | | } else { |
| | | $this->convertWebpToJpegGd($webpPath, $jpegPath); |
| | | } |
| | | |
| | | // Create attachment for the JPEG |
| | | $attachmentData = [ |
| | | 'post_mime_type' => 'image/jpeg', |
| | | 'post_title' => get_the_title($webpAttachmentId) . ' (JPEG)', |
| | | 'post_content' => '', |
| | | 'post_status' => 'inherit' |
| | | ]; |
| | | |
| | | $jpegAttachmentId = wp_insert_attachment($attachmentData, $jpegPath); |
| | | |
| | | if (is_wp_error($jpegAttachmentId)) { |
| | | throw new Exception('Failed to create JPEG attachment: ' . $jpegAttachmentId->get_error_message()); |
| | | } |
| | | |
| | | // Generate metadata |
| | | require_once(ABSPATH . 'wp-admin/includes/image.php'); |
| | | $attachData = wp_generate_attachment_metadata($jpegAttachmentId, $jpegPath); |
| | | wp_update_attachment_metadata($jpegAttachmentId, $attachData); |
| | | |
| | | // Copy alt text and other meta |
| | | $altText = get_post_meta($webpAttachmentId, '_wp_attachment_image_alt', true); |
| | | if ($altText) { |
| | | update_post_meta($jpegAttachmentId, '_wp_attachment_image_alt', $altText); |
| | | } |
| | | |
| | | return $jpegAttachmentId; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Image conversion failed...', [ |
| | | 'method' => 'convertWebpToJpeg' |
| | | ]); |
| | | return false; |
| | | } |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Convert WebP to JPEG using Imagick |
| | | */ |
| | | protected function convertWebpToJpegImagick(string $source, string $destination): void |
| | | public function getIcon():string |
| | | { |
| | | $image = new \Imagick($source); |
| | | $image->setImageFormat('jpeg'); |
| | | |
| | | // Set quality (using 85 for good balance of quality/size) |
| | | $image->setImageCompressionQuality(85); |
| | | |
| | | // Remove alpha channel and set white background |
| | | $image->setImageBackgroundColor('white'); |
| | | $image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE); |
| | | $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN); |
| | | |
| | | $image->writeImage($destination); |
| | | $image->clear(); |
| | | return $this->icon; |
| | | } |
| | | |
| | | /** |
| | | * Convert WebP to JPEG using GD |
| | | */ |
| | | protected function convertWebpToJpegGd(string $source, string $destination): void |
| | | { |
| | | $image = imagecreatefromwebp($source); |
| | | |
| | | if ($image === false) { |
| | | throw new Exception('Failed to create image from WebP source'); |
| | | } |
| | | |
| | | // Get dimensions |
| | | $width = imagesx($image); |
| | | $height = imagesy($image); |
| | | |
| | | // Create new true color image |
| | | $jpegImage = imagecreatetruecolor($width, $height); |
| | | |
| | | // Set white background (for transparency) |
| | | $white = imagecolorallocate($jpegImage, 255, 255, 255); |
| | | imagefill($jpegImage, 0, 0, $white); |
| | | |
| | | // Copy WebP image to JPEG |
| | | imagecopy($jpegImage, $image, 0, 0, 0, 0, $width, $height); |
| | | |
| | | // Save as JPEG (quality 85) |
| | | $result = imagejpeg($jpegImage, $destination, 85); |
| | | |
| | | // Clean up |
| | | imagedestroy($image); |
| | | imagedestroy($jpegImage); |
| | | |
| | | if (!$result) { |
| | | throw new Exception('Failed to save JPEG image'); |
| | | } |
| | | } |
| | | } |