'', '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 private bool $token_refresh_attempted = false; // Circuit breaker for token refresh protected array $fields = []; // The fields to generate that become credentials protected array $advanced = []; // The fields that are optional settings 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 Cache $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 Registrar.php 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 Registrar.php 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) { $this->cacheName = $this->cacheName ?: $this->service_name; $this->userID = $userID; $this->cache = Cache::for('integrations_' . $this->cacheName, $this->ttl); // Load error stats from cache $this->loadErrorStats(); $this->getPostTypes(); $this->getTaxonomies(); $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)' ]; } add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4); } 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 (!$this->isSetUp() && str_contains($name, $this->service_name)) { return false; } 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()) { 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 = []; foreach (Registrar::getRegistered('post') as $registrar) { $registrar = Registrar::getInstance($registrar); if ($registrar->hasIntegration($this->service_name)) { $postTypes[] = $registrar->getSlug(); } } update_option($key, $postTypes); } $this->syncPostTypes = $postTypes; } 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 = []; foreach (Registrar::getFeatured('is_content', 'term') as $type) { $registrar = Registrar::getInstance($type); if ($registrar->hasIntegration($this->service_name)) { $taxonomies[] = $registrar->getSlug(); } } 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(); } if (is_wp_error($result)) { return [ 'success' => false, 'message' => $result->get_error_message() ]; } return is_array($result) ? $result : ['success' => true, 'data' => $result]; } /********************************************************************* * ABSTRACT METHODS - MUST BE IMPLEMENTED BY CHILD CLASSES *********************************************************************/ /** * Initialize the integration with loaded credentials * * Called after credentials are loaded. Use this to: * - Set up API endpoints with dynamic values (e.g., account IDs) * - Initialize service-specific configurations * - Validate credentials format * * @return void */ 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 * * Output HTML form fields for configuring this integration. * Use the provided $credentials array to populate existing values. * * Guidelines: * - Use proper escaping (esc_attr, esc_html, etc.) * - Include helpful descriptions for each field * - Mark required fields clearly * - Use appropriate input types (password for secrets, url for endpoints) * * @param array $credentials Current credentials (may be empty) * @return void */ // abstract public function renderConnectionSettings(): void; /********************************************************************* * OPTIONAL OVERRIDE METHODS - IMPLEMENT AS NEEDED *********************************************************************/ /** * Save credentials using CredentialsManager */ 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' ]; } $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(); return [ 'success' => false, 'message' => 'Connection failed. Please check your credentials.', 'test_failed' => true ]; } } // Connection successful, save credentials $stored = CredentialsManager::getInstance()->storeCredentials( $this->service_name, $credentials, $this->userID ); if ($stored) { $this->updateLastTestedTime(); $this->clearCache(); return [ 'success' => true, 'message' => 'Credentials validated and saved successfully', 'reload' => true ]; } return [ 'success' => false, 'message' => 'Failed to save credentials to database' ]; } catch (\Exception $e) { // Revert to old credentials on any error $old = CredentialsManager::getInstance()->getCredentials( $this->service_name, $this->userID ); $this->credentials = $old; $this->initialize(); return [ 'success' => false, 'message' => 'Validation error: ' . $e->getMessage() ]; } } /** * Delete credentials */ public function deleteCredentials():array { $success = CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID); return [ 'success' => $success ]; } /** * Validate credentials before storing * * Override to add service-specific validation logic. * Check for required fields, format validation, etc. * * @param array $credentials Credentials to validate * @return bool True if valid */ protected function validateCredentials(array $credentials):bool { // Default: check that credentials is not empty if (empty($credentials)) { return false; } return true; } /** * Sanitize credentials before storing * * Override to clean/format credentials before storage. * Remove whitespace, normalize URLs, etc. * * @param array $credentials Raw credentials from form * @return array Sanitized credentials */ protected function sanitizeCredentials(array $credentials): array { $sanitized = []; foreach ($credentials as $key => $value) { if (is_string($value)) { $sanitized[$key] = sanitize_text_field($value); } else { $sanitized[$key] = $value; } } return $sanitized; } /** * Test connection to the external service * * Override to implement service-specific connection testing. * This method includes caching by default. * * @return bool True if connection successful */ public function testConnection(bool $force = false): bool { if (!$this->isSetUp()) { return false; } $this->ensureInitialized(); if (empty($this->credentials)) { return false; } // Cache test results to avoid excessive API calls $cacheKey = "connection_test_$this->service_name" . ($this->userID ? "_$this->userID" : ''); $cached = $this->cache->get($cacheKey); if ($cached !== false && !$force) { return (bool)$cached; } try { if ($this->isOAuthService && !$this->hasOAuthCredentials()){ //If this is an OAuth service, we might only be saving the app credentials first $result = true; } else { $result = $this->performConnectionTest(); } $this->updateLastTestedTime(); $this->cache->set($cacheKey, $result, 300); // Cache for 5 minutes return $result; } catch (Exception $e) { $this->logError('Connection test failed', ['error' => $e->getMessage()]); $this->cache->set($cacheKey, false, 60); // Cache failure for 1 minute return false; } } protected function clearCache():array { $success = $this->cache->flush(); return [ 'success' => $success, ]; } /** * Perform actual connection test * * Override this method to implement the actual connection test logic. * Default implementation returns true if credentials exist. * * @return bool True if connection successful * @throws Exception If connection fails */ protected function performConnectionTest(): bool { // Override in child class with actual test // Example: make a simple API call to verify credentials return !empty($this->credentials); } /** * Register additional WordPress hooks * * Override to register service-specific hooks beyond the default ones. * Called during construction after base hooks are registered. * * @return void */ protected function registerAdditionalHooks(): void { // Override in child classes to add service-specific hooks } /****************************************************************** 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 { if (!$this->is_healthy) { return new WP_Error('unhealthy', 'Connection marked unhealthy. Skipping fetch'); } $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.'); } $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 *********************************************************************/ /** * Queue a sync operation for processing, utilizing the OperationQueue.php * * @param string $type Operation type (sync_post, delete_post, etc.) * @param array $data Operation data * @param array $options Operation options (scheduled time, priority, etc.) * @return bool True if queued successfully */ public function queueOperation( string $type, array $data, array $options = [] ):bool { $queue = JVB()->queue(); // Add service prefix to operation type $operation_type = strtolower($this->service_name) . '_' . $type; $queued = $queue->queueOperation( $operation_type, $this->userID ?? 0, array_merge($data, ['service' => $this->service_name, 'user' => $this->userID??0]), array_merge([ 'priority' => 'normal', 'chunk_key' => $options['batch_field'] ?? null, 'chunk_size' => $options['batch_size'] ?? 10 ], $options) ); return (!is_wp_error($queued)); } /** * The filter called by OperationQueue.php processOperation * 1) Test if the operation type is the type we set in queueOperation * I usually do a switch ($operation->type) { * case strtolower($this->service_name. '_update_post'): * return $this->processPostUpdate($data); * default: * return $result; * } * 2) Process the data * 3) Return an array in this format: * [ * 'success' => true|false, * 'result' => []//anything we should pass to the operation queue. If we have any dependent operations, it will refer to this data to proceed * ] * @param WP_Error|array $result * @param object $operation * @param array $data * @return WP_Error|array */ public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array { return $result; } /********************************************************************* * UTILITY METHODS *********************************************************************/ /** * 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){ $this->registerWebhookEndpoint(); } // Let child classes register additional hooks if needed $this->registerAdditionalHooks(); if (!empty($this->syncPostTypes)) { add_action('save_post', [$this, 'handleSavePost'], 20, 3); add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); if ($this->canSync['delete']) { add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1); } } if (!empty($this->syncTaxonomies)) { add_action('saved_term', [$this, 'handleSaveTerm'], 20, 5); if ($this->canSync['delete']) { add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2); } } } public function handleAjaxResponse() { $code = $_GET['code']; $state = $_GET['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'); $state_data = get_transient('oauth_state_' . $state_key); 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); // 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 * * @param array $credentials The credentials to save * @return array Result with 'success' and 'message' keys */ public function handleConnection(array $credentials): array { try { // Sanitize credentials $sanitized = $this->sanitizeCredentials($credentials); // Validate if needed if (method_exists($this, 'validateCredentials')) { if (!$this->validateCredentials($sanitized)) { return ['success' => false, 'message' => 'Invalid Credentials']; } } // Store credentials $this->credentials = array_merge($this->credentials ?? [], $sanitized); $this->credentials['last_updated'] = time(); // Save to database $saved = $this->saveCredentials($this->credentials); if (!$saved) { return [ 'success' => false, 'message' => 'Failed to save credentials to database' ]; } // Test connection if method exists if (method_exists($this, 'testConnection')) { if (!$this->testConnection()) { // Still save but warn about connection return [ 'success' => true, 'message' => 'Credentials saved but connection test failed:' ]; } } return [ 'success' => true, 'message' => 'Connection established successfully' ]; } catch (\Exception $e) { return [ 'success' => false, 'message' => 'Error: ' . $e->getMessage() ]; } } /** * 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; } /** * Check if integration is properly configured */ public function isSetUp(): bool { if (empty($this->credentials)) { $this->loadCredentials(); } 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; } /** * Ensure service is initialized */ 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()) { // Only attempt refresh once per request if (!$this->token_refresh_attempted) { $this->token_refresh_attempted = true; // $this->logDebug('OAuth token expired, attempting refresh'); if (!$this->refreshOAuthToken()) { $this->logError('Failed to refresh expired OAuth token - stopping execution'); // Token refresh failed - DO NOT continue making API requests return; } } else { // Already attempted refresh in this request // $this->logDebug('Token refresh already attempted, skipping'); return; } } // Check if we should proactively refresh (before expiry) elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) { $this->token_refresh_attempted = true; // $this->logDebug('OAuth token should be refreshed proactively'); if (!$this->refreshOAuthToken()) { $this->logError('Failed to proactively refresh OAuth token'); // Not critical - token is still valid, so continue } } } $this->initialize(); } } /** * Switch 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->resetTokenRefreshFlag(); // ADD THIS LINE $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); } return $this->oauth['authorize'] . '?' . http_build_query($params); } /** * 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; } $request_data = [ 'client_id' => $this->credentials['client_id'], 'client_secret' => $this->credentials['client_secret'], 'refresh_token' => $this->credentials['refresh_token'], 'grant_type' => 'refresh_token' ]; $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data); if (is_wp_error($response)) { $error_message = $response->get_error_message(); if (str_contains($error_message, 'invalid_grant')) { $this->logError('OAuth refresh token is invalid - user must re-authorize', [ 'error' => $error_message ], 'critical'); // Mark unhealthy immediately $this->error_stats['consecutive_errors'] = $this->error_threshold; $this->is_healthy = false; $this->saveErrorStats(); } $this->logError('Failed to refresh OAuth token for '.$this->service_name, [ 'error' => $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: Some services return 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 */ protected function generateWebhookSignature(): 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 = Meta::forPost($postID); $service_data = []; foreach ($mapping as $wp_field => $service_field) { $value = $meta_manager->get($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 { if (!$postID || $postID === 0) { return; } $postType = jvbNoBase($post->post_type); if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (wp_is_post_revision($postID)) return; $registrar = Registrar::getInstance($postType); if (!$registrar){ return; } if (empty($this->syncPostTypes)) { return; } $settings = $registrar->hasIntegration($this->service_name)??null; if (!$settings) { return; } $settings = $registrar->getIntegrationConfig($this->service_name); if (!$settings){ return; } $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]); 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]) { return; } if ($post->post_status !== 'publish' && !$isShared) { return; } $this->handleTheSavePost($postID, $post, $update, $settings); } protected function getSyncFields(int $postID, string $type, array $additional = []):array { $meta = new Meta($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; } $registrar = Registrar::getInstance($noBase); if (!$registrar->hasFeature('is_content')) { return; } $settings = $registrar->getIntegrationConfig($this->service_name); 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(); } /** * 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->service_name; } public function getTitle():string { return $this->title; } /********************************************************************* RENDERING *********************************************************************/ // public function renderAdditionalOptions() // { // //Default: nothing. // } /** * 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 // } /** * Get service description (optional) * Override in integration classes for custom descriptions */ public function getServiceDescription(): string { 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)) { 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 * @param string $payload Raw payload body * @param string $signature Signature from headers * @param string $secret Secret key * @param string $algorithm Algorithm used (default: sha256) * @return bool */ protected function verifyWebhookSignature(string $payload, string $signature, string $secret, string $algorithm = 'sha256'): bool { if (empty($signature) || empty($secret)) { return false; } $expected = hash_hmac($algorithm, $payload, $secret); // Use hash_equals for timing-safe comparison 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( '
%s
Add this URL to your %s webhook settings
= $this->getRedirectUri(); ?>
renderOAuthConnectedOptions();
?>