<?php
|
namespace JVBase\integrations;
|
|
use Exception;
|
use JVBase\managers\CacheManager;
|
use JVBase\managers\UploadManager;
|
use JVBase\meta\MetaManager;
|
use JVBase\managers\ErrorHandler;
|
use WP_Error;
|
use WP_Post;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Base Integration Class
|
*
|
* This abstract class provides the foundation for all external service integrations.
|
* Child classes should extend this to implement specific service integrations.
|
*
|
* @abstract
|
* @since 1.0.0
|
*/
|
abstract class Integrations
|
{
|
/**
|
* 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',
|
];
|
|
|
/**
|
* 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)
|
{
|
$this->cacheName = $this->cacheName ?: $this->service_name;
|
$this->userID = $userID;
|
$this->cache = new CacheManager('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()) {
|
// 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 = [];
|
|
// 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 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];
|
|
}
|
|
/*********************************************************************
|
* 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 {
|
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 {
|
$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->clear();
|
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
|
{
|
$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));
|
|
// Standard WordPress HTTP API
|
// Use appropriate WordPress HTTP function
|
$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);
|
|
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);
|
$ttl = $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);
|
|
if ($result && $ttl > 0) {
|
$this->cache->set($cacheKey, $result, $ttl);
|
}
|
|
return $result;
|
}
|
|
/**
|
* 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()
|
{
|
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
|
*
|
* @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()) {
|
$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();
|
}
|
}
|
|
|
/**
|
* 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()
|
];
|
|
// Use a custom endpoint key for OAuth (not part of regular API)
|
// We need to handle this specially since OAuth endpoints are different
|
$oauth_endpoint = $this->oauth['token'];
|
|
// Make the request using the centralized method
|
// This automatically includes rate limiting and error handling
|
$response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
|
|
if (is_wp_error($response)) {
|
$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'])) {
|
return [
|
'access_token' => $response['access_token'],
|
'refresh_token' => $response['refresh_token'] ?? '',
|
'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default
|
'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
|
*/
|
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 = 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->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)) {
|
$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
|
* @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(
|
'<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('save');
|
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()) ? :get_the_permalink();
|
?>
|
|
<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;
|
}
|
|
protected function getSupportedImage(int $imgID):int
|
{
|
//If this integration supports webp, we can just send the original image id
|
if ($this->supportsWebp) {
|
return $imgID;
|
}
|
//Test if it is in webp format
|
$mimeType = get_post_mime_type($imgID);
|
if ($mimeType !== 'image/webp') {
|
return $imgID;
|
}
|
|
//Test if we already have converted this image
|
$jpegVersion = get_post_meta($imgID, BASE.'jpeg_version', true);
|
if ($jpegVersion !== '' && is_int($jpegVersion)) {
|
return $jpegVersion;
|
}
|
|
$converted = $this->convertWebpToJpeg($imgID);
|
if ($converted) {
|
update_post_meta($imgID, BASE . 'jpeg_version', $converted);
|
return $converted;
|
}
|
|
return $imgID;
|
}
|
|
/**
|
* Convert WebP image to JPEG
|
*
|
* @param int $webpAttachmentId
|
* @return int|false Image ID on success, false on failure.
|
*/
|
protected function convertWebpToJpeg(int $webpAttachmentId):int|false
|
{
|
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;
|
}
|
}
|
|
/**
|
* Convert WebP to JPEG using Imagick
|
*/
|
protected function convertWebpToJpegImagick(string $source, string $destination): void
|
{
|
$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();
|
}
|
|
/**
|
* 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');
|
}
|
}
|
}
|