JVBase.php
@@ -1,52 +1,52 @@ <?php namespace JVBase; use JVBase\blocks\CustomBlocks; use JVBase\admin\SEOAdmin; use JVBase\base\SchemaHelper; use JVBase\base\Site; use JVBase\blocks\CustomBlocks; use JVBase\managers\AdminPages; use JVBase\managers\ApprovalManager; use JVBase\managers\Dashboard\DashboardManager; use JVBase\managers\DirectoryManager; use JVBase\managers\EmailManager; use JVBase\managers\ErrorHandler; use JVBase\managers\FavouritesManager; use JVBase\managers\InvitationsManager; use JVBase\managers\LoginManager; use JVBase\managers\MagicLinkManager; use JVBase\managers\NotificationManager; use JVBase\managers\queue\Queue; use JVBase\managers\Dashboard\DashboardManager; use JVBase\managers\DirectoryManager; use JVBase\managers\ReferralManager; use JVBase\managers\RoleManager; use JVBase\managers\SEO\render\SchemaOutput; use JVBase\admin\SEOAdmin; use JVBase\managers\AdminPages; use JVBase\managers\NotificationManager; use JVBase\managers\TaxonomyRelationships; use JVBase\managers\UserTermsManager; use JVBase\registrar\Registrar; use JVBase\rest\routes\FeedRoutes; use JVBase\rest\routes\FavouritesRoutes; use JVBase\rest\routes\IntegrationsSquareRoutes; use JVBase\rest\routes\IntegrationsHelcimRoutes; use JVBase\rest\routes\NotificationsRoutes; use JVBase\rest\routes\AdminRoutes; use JVBase\rest\routes\ApprovalRoutes; use JVBase\rest\routes\ContentRoutes; use JVBase\rest\routes\TermRoutes; use JVBase\rest\routes\UploadRoutes; use JVBase\rest\routes\SettingsRoutes; use JVBase\rest\routes\ContentTermsRoutes; use JVBase\rest\routes\SEORoutes; use JVBase\rest\routes\QueueRoutes; use JVBase\rest\routes\ErrorRoutes; use JVBase\rest\routes\FavouritesRoutes; use JVBase\rest\routes\FeedRoutes; use JVBase\rest\routes\FormRoutes; use JVBase\rest\routes\IntegrationsHelcimRoutes; use JVBase\rest\routes\IntegrationsRoutes; use JVBase\rest\routes\IntegrationsSquareRoutes; use JVBase\rest\routes\Invitations; use JVBase\rest\routes\LoginRoutes; use JVBase\rest\routes\NewsRoutes; use JVBase\rest\routes\NotificationsRoutes; use JVBase\rest\routes\OptionsRoutes; use JVBase\rest\routes\QueueRoutes; use JVBase\rest\routes\ReferralRoutes; use JVBase\rest\routes\ResponseRoutes; use JVBase\rest\routes\OptionsRoutes; use JVBase\rest\routes\SEORoutes; use JVBase\rest\routes\SettingsRoutes; use JVBase\rest\routes\TermRoutes; use JVBase\rest\routes\UploadRoutes; use JVBase\rest\routes\VoteRoutes; use JVBase\rest\routes\Invitations; use JVBase\rest\routes\ApprovalRoutes; use JVBase\rest\routes\AdminRoutes; use JVBase\rest\routes\IntegrationsRoutes; use JVBase\base\SchemaHelper; use UserRoutes; if (!defined('ABSPATH')) { @@ -65,16 +65,16 @@ protected CustomBlocks $customBlocks; protected array $serviceMap = [ 'maps' => 'JVBase\integrations\GoogleMaps', 'square' => 'JVBase\integrations\Square', 'facebook' => 'JVBase\integrations\Facebook', 'helcim' => 'JVBase\integrations\Helcim', 'instagram' => 'JVBase\integrations\Instagram', 'bluesky' => 'JVBase\integrations\BlueSky', 'gmb' => 'JVBase\integrations\GoogleMyBusiness', 'cloudflare' => 'JVBase\integrations\Cloudflare', 'umami' => 'JVBase\integrations\Umami', 'postmark' => 'JVBase\integrations\PostMark', 'maps' => 'JVBase\integrations\services\GoogleMaps', 'square' => 'JVBase\integrations\services\Square', 'facebook' => 'JVBase\integrations\services\Facebook', 'helcim' => 'JVBase\integrations\services\Helcim', 'instagram' => 'JVBase\integrations\services\Instagram', 'bluesky' => 'JVBase\integrations\services\BlueSky', 'gmb' => 'JVBase\integrations\services\GoogleMyBusiness', 'cloudflare' => 'JVBase\integrations\services\Cloudflare', 'umami' => 'JVBase\integrations\services\Umami', 'postmark' => 'JVBase\integrations\services\PostMark', ]; public static function getInstance(): JVB @@ -307,9 +307,11 @@ // // return $this->serviceMap[$service]::getInstance($userID); // } return is_null($userID) ? $this->integrations[$service]??null : (array_key_exists($service, $this->integrations) ? $this->serviceMap[$service]::getInstance($userID) : null); $instance = $this->integrations[$service]??null; if ($instance) { $instance->switchUser($userID); } return $instance; } public function userCanConnect(string $service, int $userID): bool build/menu/style-index-rtl.css
@@ -1 +1 @@ .info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity>label{right:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1,main>section *{max-width:var(--wide)}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content} .info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity.quantity>label{right:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1{max-width:var(--wide)}main>section *{max-width:var(--wide)!important}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content} build/menu/style-index.css
@@ -1 +1 @@ .info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity>label{left:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1,main>section *{max-width:var(--wide)}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content} .info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity.quantity>label{left:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1{max-width:var(--wide)}main>section *{max-width:var(--wide)!important}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content} inc/integrations/Admin.php
New file @@ -0,0 +1,233 @@ <?php namespace JVBase\integrations; use JVBase\meta\Form; if (!defined('ABSPATH')) { exit; } trait Admin { use _Base; //An array of AdminAction objects protected array $actions = []; protected array $instructions = []; protected array $fields = []; protected function initializeActions():void { $actions = [ [ 'name' => 'Save Credentials', 'label' => 'Save Changes', 'icon' => 'floppy-disk', 'action' => [$this, 'saveCredentials'] ], [ 'name' => 'Clear Credentials', 'icon' => 'broom', 'action' => [$this, 'clearCredentials'] ], ]; foreach ($actions as $a) { $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null); } } public function addAction(string $name, callable $callback, ?string $slug = null, ?string $icon = null, ?string $label = null):void { $action = new AdminActions($name, $callback); if (!is_null($slug)) { $action->setSlug($slug); } if (!is_null($icon)) { $action->setIcon($icon); } if (!is_null($label)) { $action->setLabel($label); } $this->actions[$action->getSlug()] = $action; } /** * @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 (!array_key_exists($action, $this->actions)) { $this->logError('processAction', 'Attempted action not setup: '.print_r(['action' => $action, 'service' => $this->service_name], true)); return $this->response(false, 'Attempted action not set up'); } $theAction = $this->actions[$action]; if (!$theAction->checkPermission()) { $this->logError('processAction', 'User has no capabilities to perform this action: '.print_r(['action' => $action, 'service' => $this->service_name], true)); return $this->response(false, 'Insufficient permissions'); } $result = $theAction->handleCallback($data); if (is_wp_error($result)) { $this->logError('processAction', $result->get_error_message()); return $this->response(false, $result->get_error_message()); } return $result; } public function response(bool $success, string $message):array { return [ 'success' => $success, 'message' => $message ]; } public function renderConnection():string { if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) { return ''; } $credentials = $this->loadCredentials(); $isConnected = !empty($credentials); $meta = ''; if ($isConnected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0) { $meta = sprintf( '<div class="meta"> <small>Last updated: %s ago</small> </div>', human_time_diff($credentials['updated_at']) ); } $form = sprintf( '<form id="%s" class="integration %s" data-service="%s" data-save="integrations"> <div class="header row x-btw"> <h3>%s</h3> <div class="setup"> <span class="indicator">%s</span> <span class="text">%sSet Up</span> </div> </div> %s', $this->service_name, $isConnected ? 'connected' : 'disconnected', esc_attr($this->service_name), esc_html($this->title), $isConnected ? '●' : '○', $isConnected ? '' : 'Not ', $meta, ); if (!empty($this->instructions)) { $form .= sprintf( '<details%s> <summary>Instructions</summary> <ol>%s</ol> </details>', $isConnected ? '' : ' open', implode('',array_map(function($el) { return sprintf('<li>%s</li>', $el); }, $this->instructions)) ); } if (current_user_can('manage_options')) { $form .= sprintf( '<details class="initial-setup"%s> <summary>Setup</summary> %s %s </details>', $isConnected ? '' : ' open', $this->outputFields($this->credentialFields), method_exists($this, 'renderWebhookUrl') ? $this->renderWebhookUrl() : '' ); } if (method_exists($this, 'renderOAuthConnectionForm')) { $form .= $this->renderOAuthConnectionForm(); } if (!empty($this->optionalFields) || $this->hasExtraOptions) { $form .= '<div class="integration-content">'; if (!empty($this->optionalFields)) { $form .= sprintf( '<details class="advanced"> <summary>Advanced Settings</summary>%s</details>', $this->outputFields($this->optionalFields) ); } if ($this->hasExtraOptions) { $form .= sprintf( '<a href="%s" class="btn">More Settings</a>', $this->determineOptionPage() ); } $form .= '</div>'; } $form .= $this->outputActionButtons(); $form .= '</form>'; return $form; } protected function outputActionButtons():string { return sprintf('<div class="actions row x-btw wrap">%s</div>',implode('', array_map(function($action) { $label = ''; if (!empty ($action->getIcon())) { $label .= $this->outputIcon($action->getIcon()); } $label .= $action->getLabel(); return sprintf( '<button type="button" data-action="%s"%s%s>%s</button>', $action->getSlug, $action->getName(), empty($action->getConfirm()) ? '' : ' data-confirm="'.$action->getConfirm().'"', $label ); }, array_filter($this->actions, function ($action) { return $action->checkPermission(); })))); } protected function outputFields(array $fields):string { $credentials = $this->loadCredentials(); $fields = array_filter($fields, function ($field) { return $field->checkPermission(); }); return implode('', array_map(function($f) use ($credentials) { $value = array_key_exists($f->name, $credentials) ? $credentials[$f->name] : ''; $config = $f->getConfig(); if (!empty($value)) { $config['readonly'] = true; } $config['base'] = $this->service_name.'_'; return Form::render($f->name, $value, $f->getConfig()); }, $fields)); } public function addField(string $name, string $label, string $type, callable|string|bool $permission):Field { $field = new Field($name, $label, $type, $permission); $this->fields[$name] = $field; return $field; } public function setFieldRequired(string $name):bool { if (!array_key_exists($name, $this->fields)) { return false; } $this->fields[$name]->setRequired(); return true; } } inc/integrations/AdminActionResult.php
New file @@ -0,0 +1,11 @@ <?php namespace JVBase\integrations; if (!defined('ABSPATH')) { exit; } trait AdminActionResult { public bool $success; public array $result; } inc/integrations/AdminActions.php
New file @@ -0,0 +1,105 @@ <?php namespace JVBase\integrations; use Closure; if (!defined('ABSPATH')) { exit; } class AdminActions { public string $slug; public string $name; public string $label; public ?string $icon = null; public string|Closure $permission = 'manage_options'; public Closure $callback; public string $confirm = ''; public function __construct(string $name, callable $callback) { $this->name = $name; $this->setSlug(); } public function setSlug(?string $slug = null):void { if (is_null($slug)) { $slug = $this->name; } $this->slug = $this->sanitizeSlug($slug); } protected function sanitizeSlug(string $slug):string { return str_replace('-','_',sanitize_title($slug)); } public function getSlug():string { return $this->slug; } public function setIcon(string $icon):void { $this->icon = $icon; } public function getIcon():string|null { return $this->icon; } public function setLabel(string $label):void { $this->label = $label; } public function getLabel():string { if (isset($this->label)) { return $this->label; } return $this->name; } public function setCallback(callable $callback):void { $this->callback = Closure::fromCallable($callback); } public function handleCallback(?array $data = null):mixed { if (is_callable($this->callback)) { return ($this->callback)($data); } error_log('[AdminActions]::handleCallback No callback set for '.$this->name. ' action'); return false; } public function setPermission(string $permission):void { $this->permission = $permission; } public function checkPermission():bool { if (is_string($this->permission)) { return current_user_can($this->permission); } else if (is_callable($this->permission)) { return ($this->permission)(); } error_log('Check Permission failed as no permission set. '.print_r([ 'permission' => $this->permission, ], true)); return false; } public function setConfirm(string $confirm):void { $this->confirm = $confirm; } public function getConfirm():string { return $this->confirm; } public function getName():string { return $this->name; } } inc/integrations/Ajax.php
New file @@ -0,0 +1,10 @@ <?php namespace JVBase\integrations; if (!defined('ABSPATH')) { exit; } trait Ajax { } inc/integrations/Auth.php
File was renamed from inc/integrations/CredentialsManager.php @@ -1,15 +1,14 @@ <?php namespace JVBase\integrations; use JVBase\managers\CustomTable; if (!defined('ABSPATH')) { exit; } class CredentialsManager class Auth { private static ?CredentialsManager $instance = null; private static ?Auth $instance = null; private string $encryption_key; private CustomTable $table; @@ -24,11 +23,16 @@ $table = CustomTable::for('integrations'); $table->setColumns([ 'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT', 'user_id' => $table->getUserIDType().' NOT NULL', 'user_id' => $table->getUserIDType().' DEFAULT NULL', 'integration' => "ENUM('bluesky','cloudflare','facebook','google-maps','gmb','helcim','instagram','postmark','square','umami') NOT NULL", 'credentials' => 'varchar(255) DEFAULT NULL', 'credentials' => 'varchar(1000) DEFAULT NULL', 'created_at' => 'datetime DEFAULT CURRENT_TIMESTAMP', 'updated_at' => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' 'updated_at' => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', 'last_tested' => 'datetime DEFAULT NULL', 'is_healthy' => 'tinyint(1) DEFAULT 1', 'webhook_healthy' => 'tinyint(1) DEFAULT 1', 'request_healthy' => 'tinyint(1) DEFAULT 1', 'oauth_healthy' => 'tinyint(1) DEFAULT 1', ]); $table->setKeys([ @@ -46,7 +50,7 @@ $this->table = $table; } public static function getInstance(): CredentialsManager public static function getInstance(): Auth { if (self::$instance === null) { self::$instance = new self(); @@ -60,10 +64,19 @@ public function storeCredentials(string $service, array $credentials, ?int $userID = null): bool { $find = $this->findBy($service, $userID); error_log('Storing Credentials: '.print_r($credentials, true)); $update = [ 'credentials' => $this->encrypt(json_encode($credentials)) ]; return (bool)$this->table->findOrCreate($find, $update); error_log('Find: '.print_r($find, true)); error_log('Update: '.print_r($update, true)); $updated = $this->table->findOrCreate($find, $update); error_log('Last error: '.print_r($this->table->getLastError(), true)); error_log('Updated: '.print_r($updated, true)); return (bool)$updated; } /** @@ -92,7 +105,7 @@ protected function findBy(string $service, ?int $userID = null):array { return [ 'user_id' => is_null($userID) ? 0 : $userID, 'user_id' => $userID, 'integration'=> $service ]; } @@ -142,4 +155,33 @@ { return $this->table->pluck('user_id', ['integration' => $service]); } public function updateTested(string $service, ?int $userID = null):void { $find = $this->findBy($service, $userID); $this->table->update([ 'last_tested' => time(), ], $find); } public function markUnhealthy(string $service, string $type = '', ?int $userID = null):void { $update = []; switch ($type) { case 'oauth': $update['oauth_healthy'] = 0; break; case 'request': $update['request_healthy'] = 0; break; case 'webhook': $update['webhook_healthy'] = 0; break; default: $update['is_healthy'] = 0; break; } $find = $this->findBy($service, $userID); $this->table->update($update, $find); } } inc/integrations/Field.php
New file @@ -0,0 +1,68 @@ <?php namespace JVBase\integrations; use Closure; if (!defined('ABSPATH')) { exit; } class Field { public string $name; public string $label; public string $type; public bool $required; public string|Closure|bool $permission; protected array $options; public function __construct(string $name, string $label, string $type, callable|string|bool $permission) { $this->name = $name; $this->label = $label; $this->type = $type; $this->permission = $permission; } public function checkPermission():bool { if (is_bool($this->permission)) { return $this->permission; } if (is_string($this->permission)) { return current_user_can($this->permission); } if (is_callable($this->permission)) { return ($this->permission)(); } error_log('Check Permission failed as no permission set. '.print_r([ 'permission' => $this->permission, 'field' => $this->name, ], true)); return false; } public function setRequired():void { $this->required = true; } public function setOptions(array $options):void { $this->options = $options; } public function getConfig():array { $conf = [ 'name' => $this->name, 'label' => $this->label, 'type' => $this->type, ]; if ($this->required) { $conf['required'] = true; } if ($this->options) { $conf['options'] = $this->options; } return $conf; } } inc/integrations/Integrations.php
@@ -3,21 +3,17 @@ use Exception; use JVBase\managers\Cache; use JVBase\managers\ErrorHandler; use JVBase\managers\queue\executors\IntegrationExecutor; use JVBase\managers\queue\mergers\DefaultMerger; use JVBase\managers\queue\TypeConfig; use JVBase\managers\UploadManager; use JVBase\meta\Form; use JVBase\meta\Meta; use JVBase\managers\ErrorHandler; use JVBase\registrar\helpers\AddIntegrationFields; use JVBase\registrar\Registrar; use WP_Error; use WP_Post; use WP_Query; use WP_REST_Request; use WP_REST_Response; use WP_Term; use WP_User; if (!defined('ABSPATH')) { exit; @@ -34,85 +30,30 @@ */ abstract class Integrations { use _Base; //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard public static bool $hasExtraOptions = false; public bool $hasExtraOptions = false; public bool $hasBatchCreate = false; public bool $hasBatchUpdate = false; public bool $hasBatchDelete = false; public bool $canCreateOnUpdate = false; /** * Queue types * These types match with IntegrationExecutor */ protected static string $syncTo; protected static string $deleteFrom; protected static string $syncFrom; protected static string $syncCustomer; protected static string $import; /** * 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 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 = []; @@ -127,24 +68,6 @@ '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 @@ -163,74 +86,51 @@ * 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; protected 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->cache = Cache::for('integrations_' . $this->cacheName); $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)' ]; if (method_exists($this, 'setQueueTypes')) { $this->setQueueTypes(); } self::$syncTo = $this->service_name.'_sync_to'; self::$deleteFrom = $this->service_name.'_delete_from'; self::$syncFrom = $this->service_name.'_sync_from'; //TODO: What is the difference between sync_from and import? self::$import = $this->service_name.'import_from'; $this->initializeRateLimiters(); if (method_exists($this, 'initializeActions')) { $this->initializeActions(); } if (method_exists($this, 'addOAuthActions')) { $this->addOAuthActions(); } add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4); } protected function initializeRateLimiters():void { $key = $this->service_name; if (!is_null($this->userID)) { $key .= '_'.$this->userID; } protected function addFilters():bool { return is_null($this->userID); } if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) { $this->requestLimiter = new RateLimits($key); } if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) { $key .= '_oauth'; $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]); } } protected function setContentTypes():void { @@ -238,7 +138,6 @@ } public function getContentTypes(string $type):array { return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes; } @@ -256,7 +155,7 @@ public function handleOAuthConnect(): array { if (!$this->isOAuthService) { if (!$this->hasOAuth) { return ['success' => false, 'message' => 'Not an OAuth service']; } @@ -355,7 +254,7 @@ // if (!empty($this->oauth['redirect_uri'])) { // return $this->oauth['redirect_uri']; // } if ($this->isOAuthService) { if ($this->hasOAuth) { return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback'); } // Changed from admin-ajax.php to REST endpoint @@ -393,71 +292,6 @@ } } /** * @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 @@ -475,22 +309,6 @@ */ 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 @@ -513,7 +331,7 @@ * OPTIONAL OVERRIDE METHODS - IMPLEMENT AS NEEDED *********************************************************************/ /** * Save credentials using CredentialsManager * Save credentials using Auth */ public function saveCredentials(array $credentials, bool $test = true):array { @@ -552,7 +370,7 @@ // Connection successful, save credentials $stored = CredentialsManager::getInstance()->storeCredentials( $stored = Auth::getInstance()->storeCredentials( $this->service_name, $credentials, $this->userID @@ -576,7 +394,7 @@ } catch (\Exception $e) { // Revert to old credentials on any error $old = CredentialsManager::getInstance()->getCredentials( $old = Auth::getInstance()->getCredentials( $this->service_name, $this->userID ); @@ -595,7 +413,7 @@ */ public function deleteCredentials():array { $success = CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID); $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); return [ 'success' => $success ]; @@ -759,285 +577,18 @@ * 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 @@ -1101,114 +652,17 @@ *********************************************************************/ /** * 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 { //Handled by IntegrationExecutor now // add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); if (is_null($this->userID)) { 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 && is_null($this->userID)){ if (method_exists($this, 'registerOAuthCallbacks')) { $this->registerOAuthCallbacks(); } if (method_exists($this, 'registerWebhookEndpoint')) { $this->registerWebhookEndpoint(); } // Let child classes register additional hooks if needed @@ -1280,62 +734,7 @@ { //Empty. Integration extensions can register additional operation types from here. } 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 * @@ -1393,45 +792,19 @@ } } /** * Load credentials from secure storage */ protected function loadCredentials(): void { $this->credentials = CredentialsManager::getInstance()->getCredentials($this->service_name, $this->userID); } public function getCredentials(): array { $this->ensureInitialized(); return $this->credentials; return $this->loadCredentials(); } /** * Check if integration is properly configured */ public function isSetUp(): bool public function hasOAuthCredentials(?int $userID = null): bool { if (empty($this->credentials)) { $this->loadCredentials(); if ($userID !== $this->userID) { $this->switchUser($userID); } return !empty($this->credentials); } public function hasOAuthCredentials(): bool { if (!$this->isOAuthService){ return false; } $required = ['client_id', 'client_secret', 'access_token']; foreach ($required as $field) { if (empty($this->credentials[$field]) || !is_string($this->credentials[$field]) || $this->credentials[$field] === '') { return false; } } return true; return $this->hasOAuth && $this->hasValidOAuth(); } @@ -1443,42 +816,7 @@ 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(); } $this->initialize(); } @@ -1526,9 +864,6 @@ // 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, @@ -1539,57 +874,10 @@ ]; } /** * 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 @@ -1597,7 +885,7 @@ public function getOAuthUrl(?string $return_url = null): string { if (!$this->isOAuthService) { if (!$this->hasOAuth) { return ''; } @@ -1663,119 +951,7 @@ 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 @@ -1870,7 +1046,7 @@ */ protected function shouldRefreshToken(): bool { if (!$this->isOAuthService || $this->refresh_interval === 0) { if (!$this->hasOAuth || $this->refresh_interval === 0) { return false; } @@ -1908,7 +1084,7 @@ */ public function getTokenStatus(): string { if (!$this->isOAuthService) { if (!$this->hasOAuth) { return 'not_oauth'; } @@ -1938,7 +1114,7 @@ */ protected function refreshOAuthToken(): bool { if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) { if (!$this->hasOAuth || empty($this->credentials['refresh_token'])) { return false; } @@ -1992,7 +1168,7 @@ */ public function revokeOAuthAccess(): bool { if (!$this->isOAuthService || empty($this->oauth['revoke'])) { if (!$this->hasOAuth || empty($this->oauth['revoke'])) { return false; } @@ -2002,7 +1178,7 @@ ]); } return CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID); return Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); } @@ -2322,7 +1498,7 @@ */ protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false { if ($this->isOAuthService && in_array($endpoint, $this->oauth)) { if ($this->hasOAuth && in_array($endpoint, $this->oauth)) { return $endpoint; } if (is_array($this->apiBase)) { @@ -2390,65 +1566,7 @@ 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 @@ -2549,100 +1667,8 @@ $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 @@ -2698,69 +1724,6 @@ return "Manage your {$this->getServiceName()} integration settings."; } /** * Get service key for consistency */ public function getServiceKey(): string { return strtolower($this->service_name); } /** * 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 @@ -2782,110 +1745,14 @@ 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 ''; } @@ -2970,7 +1837,7 @@ echo Form::render($name, '', $config); } } if ($this->handleWebhooks) { if ($this->hasWebhooks) { echo $this->renderWebhookUrl(); } ?> @@ -3108,26 +1975,14 @@ } /** * 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()); $cred = Auth::getInstance(); $cred->updateTested($this->service_name, $this->userID); } public function handleAdminPost(): void @@ -3321,155 +2176,16 @@ if ($jpegVersion !== '' && is_int($jpegVersion)) { return $jpegVersion; } $converted = $this->convertWebpToJpeg($imgID); if ($converted) { update_post_meta($imgID, BASE . 'jpeg_version', $converted); return $converted; $uploader = new UploadManager(); $converted = $uploader->convertImageTo($imgID, 'jpeg', 80, false); if ($converted && !is_wp_error($converted)) { update_post_meta($imgID, BASE . 'jpeg_version', $converted['attachment_id']); return $converted['attachment_id']; } return $imgID; } /** * Convert WebP image to JPEG * * @param int $webpAttachmentId * @return int|false Image ID on success, false on failure. */ 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'); } } /** * Reset token refresh attempt flag * Called automatically when switching users */ protected function resetTokenRefreshFlag(): void { $this->token_refresh_attempted = false; } public function getAllowedContent():array { @@ -3490,15 +2206,7 @@ return $this->icon; } public function getServiceItemID(int $itemID, string $type = 'post'):string { return match($type) { 'post' => Meta::forPost($itemID)->get("_{$this->service_name}_item_id"), 'term','taxonomy' => Meta::forTerm($itemID)->get("_{$this->service_name}_item_id"), 'user' => Meta::forUser($itemID)->get("_{$this->service_name}_item_id"), default => '' }; } public function canBatchUpdate():bool { @@ -3512,468 +2220,4 @@ { return $this->hasBatchDelete; } protected function updateItemStatus(int|array $items, string $status = 'pending', string $type = 'post'):void { if (!is_array($items)) { $items = [$items]; } foreach ($items as $ID) { match ($type) { 'post' => Meta::forPost($ID)->set('_'.$this->service_name.'_sync_status', $status), 'term' => Meta::forTerm($ID)->set('_'.$this->service_name.'_sync_status', $status), 'user' => Meta::forUser($ID)->set('_'.$this->service_name.'_sync_status', $status), default => false }; } } public function updateBatchToService(array $data):array { $type = $data['type']??'post'; $newlyCreated = []; if (!$this->canCreateOnUpdate) { $created = array_filter($data['items'], function($ID) use ($type) { return !empty($this->getServiceItemID($ID, $type)); }); //Test to see if we have any that haven't been created yet. //For some services, items may have to be created before they can be updated if (count($created) !== count($data['items'])) { $notCreated = array_filter($data['items'], function($ID) use ($type) { return empty($this->getServiceItemID($ID, $type)); }); $newData = $data; $newData['items'] = $notCreated; $newlyCreated = $this->createBatchToService($newData); } // If we don't have any that are created, just send the noUpdatedItems response, with any newly created items added if (empty($created)) { return $this->noUpdatedItems($newlyCreated); } $data['items'] = $created; } $items = $this->formatItems($data['items'], $type); if (empty($items)) { return $this->noUpdatedItems($newlyCreated); } if ($this->hasBatchUpdate) { $response = $this->sendBatchUpdate($items); if (!is_wp_error($response)) { $result = $this->processBatchUpdateResponse($data, $response); } else { $this->logError('Batch update failed',[ 'method' => 'updateBatchToService', 'item_ids' => $data['items'], 'error' => $response ]); $this->updateItemStatus($data['items'], 'error'); $result = [ 'outcome' => 'failed_permanent', 'result' => 'Could not update items' ]; } } else { $success = $errors = []; //Does not have batch update, manually update each one foreach ($items as $item) { $itemResult = $this->updateOne($item); if (!is_wp_error($itemResult)) { $success[] = $itemResult; } else { $errors[] = $itemResult; } } $result = [ 'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'), 'result' => [ 'success' => $success, 'errors' => $errors ] ]; } return array_merge($result, $newlyCreated); } /** * To be implemented by integration extension. Updates a single item * @param array $item * @return array|WP_Error */ protected function updateOne(array $item):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOne method'); } /** * Overridden by child classes * @param array $items * @return array|WP_Error */ protected function sendBatchUpdate(array $items):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchUpdate'); } /** * Overridden by child classes * @param array $items * @return array|WP_Error */ protected function sendBatchCreate(array $items):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchCreate'); } /** * To be implemented by extensions * @param array $data * @param array $response * @return array */ protected function processBatchUpdateResponse(array $data, array $response):array { return [ 'outcome' => 'failed', 'result' => [ 'message' => $this->service_name.' should implement processBatchUpdateResponse.' ] ]; } /** * To be implemented by extensions * @param array $data * @param array $response * @return array */ protected function processBatchCreateResponse(array $data, array $response):array { return [ 'outcome' => 'failed', 'result' => [ 'message' => $this->service_name.' should implement processBatchCreateResponse.' ] ]; } /** * Defaults to an array of formatted items. Can be overridden in child classes * @param array $itemIDs * @param string $type * @return array */ protected function formatItems(array $itemIDs, string $type):array { $items = []; foreach ($itemIDs as $ID) { try { $items[] = $this->formatForService($ID, $type); } catch (Exception $e){ $this->logError('Could not format Item for service',[ 'itemID' => $ID, 'type' => $type, 'message' => $e->getMessage(), ]); } } return $items; } /** * Must be defined according to how each service needs it to be * @param int $itemID * @param string $type * @return array * @throws Exception */ protected function formatForService(int $itemID, string $type = 'post'):array { throw new Exception('formatForService must be implemented by child class'); } protected function noUpdatedItems(array $created):array { $result = [ 'outcome' => 'success', 'result' => [ 'message' => 'No items to update', 'updated' => [], 'errors' => [], ] ]; if (!empty($created)) { error_log('Result before: '.print_r($result, true)); $result = array_merge($result, $created); error_log('Result after merge: '.print_r($result, true)); } return $result; } protected function noCreatedItems(array $updated):array { $result = [ 'outcome' => 'success', 'result' => [ 'message' => 'No items to create', 'created' => [], 'errors' => [], ] ]; if (!empty($updated)) { $result = array_merge($result, $updated); } return $result; } public function createBatchToService($data):array { $type = $data['type']??'post'; //Check if any of the submitted ids are already created $created = array_filter($data['items'], function($ID) use ($type) { return !empty($this->getServiceItemID($ID, $type)); }); $updated = []; if (!empty($created)) { $updateData = $data; $updateData['items'] = $created; $updated = $this->updateBatchToService($data); //remove any updated items from the original items to process $data['items'] = array_filter($data['items'], function ($ID) use ($created) { return !in_array($ID, $created); }); } $items = $this->formatItems($data['items'], $type); if (empty($items)) { return $this->noCreatedItems($updated); } $response = [ 'outcome' => 'failed', 'result' => [ 'message' => 'No result' ] ]; if ($this->hasBatchCreate) { $response = $this->sendBatchCreate($items); if (!is_wp_error($response)) { $result = $this->processBatchCreateResponse($data, $response); } else { $this->logError('Batch create failed',[ 'method' => 'createBatchToService', 'item_ids' => $data['items'], 'error' => $response ]); $this->updateItemStatus($data['items'], 'error'); $result = [ 'outcome' => 'failed_permanent', 'result' => 'Could not update items' ]; } } else { $success = $errors = []; foreach ($items as $item) { $itemResult = $this->createOne($item); if (!is_wp_error($itemResult)) { $success[] = $itemResult; } else { $errors[] = $itemResult; } } $result = [ 'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'), 'result' => [ 'success' => $success, 'errors' => $errors ] ]; } return array_merge($result, $updated); } /** * To be implemented by integration extension. Updates a single item * @param array $item * @return array|WP_Error */ protected function createOne(array $item):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOne'); } public function findWPItem(string $integrationID, ?string $type = null):array|false { if (empty($integrationID)) { error_log('Integrations::findWPItem No Integration ID received.'); return false; } $types = ['post', 'term', 'user']; if (!is_null($type) && !in_array($type, $types)) { error_log('Integrations::findWPItem invalid type sent: '.$type.'; defaulting to checking all'); $type = null; } if (!is_null($type)) { return $this->queryWPType($integrationID, $type); } else { foreach ($types as $type) { $check = $this->queryWPType($integrationID, $type); if ($check) { return $check; } } } return false; } /** * @param string $integrationID * @param string|null $type * @return array|false if success, an array of $ID and $type */ public function queryWPType(string $integrationID, ?string $type = null):array|false { if (!in_array($type, ['post', 'term', 'user'])) { return $this->findWPItem($integrationID); } return match($type) { 'post' => $this->findWPPost($integrationID), 'term' => $this->findWPTerm($integrationID), 'user' => $this->findWPUser($integrationID), default => false }; } public function findWPPost(string $integrationID):array|false { if (empty($integrationID)) { return false; } $get = new WP_Query([ 'meta_query' => [ 'key' => BASE."_{$this->service_name}_item_id", 'value' => $integrationID, ], 'fields' => 'ids', 'posts_per_page' => 1, ]); return $get->have_posts() && !empty($get->posts) ? [$get->posts[0], 'post'] : false; } public function findWPTerm(string $integrationID):array|false { if (empty($integrationID)) { return false; } $get = get_terms([ 'meta_query' => [ 'key' => BASE."_{$this->service_name}_item_id", 'value' => $integrationID, ], 'fields' => 'ids', 'hide_empty'=> true, 'number' => 1 ]); return is_wp_error($get) || empty($get) ? false : [$get[0], 'term']; } public function findWPUser(string $integrationID):array|false { if (empty($integrationID)) { return false; } $get = get_users([ 'meta_query' => [ 'key' => BASE."_{$this->service_name}_item_id", 'value' => $integrationID, ], 'fields' => 'ids', 'number' => 1, ]); return is_wp_error($get) || empty($get) ? false : [$get[0], 'user']; } /********************************************************************************* * Syncing WP items from Service *********************************************************************************/ /** * @param array $itemData * @return bool * @throws Exception */ protected function createItemFromService(array $itemData):bool { throw new Exception('createItemFromService must be implemented by child class '.$this->service_name); } /** * @param array $itemData * @return bool * @throws Exception */ protected function updateItemFromService(array $itemData):bool { throw new Exception('updateItemFromService must be implemented by child class '.$this->service_name); } /** * @param array $itemData * @return bool * @throws Exception */ protected function deleteItemFromService(array $itemData):bool { throw new Exception('deleteItemFromService must be implemented by child class '.$this->service_name); } /**************************************************************** * CONNECT to a user's integration ****************************************************************/ protected array $connections = []; public function connectAs(?int $userID = null):bool { if (!$this->userCanConnect($userID)) { return false; } $this->cleanup(); $this->userID = $userID; $this->ensureInitialized(); return true; } public function userCanConnect(?int $userID):bool { $user = get_userdata($userID); if (!$user || is_wp_error($user)) { return false; } if (current_user_can('manage_options')) { return true; } $role = jvbUserRole($userID); $registrar = Registrar::getInstance($role); if (!$registrar || !$registrar->hasIntegration($this->service_name)) { return false; } return (bool) Meta::forUser($userID)->get('integration_'.$this->service_name.'_connected'); } protected function cleanup():void { $this->userID = false; $this->credentials = []; $this->request_history = []; $this->resetTokenRefreshFlag(); } } inc/integrations/OAuth.php
New file @@ -0,0 +1,351 @@ <?php namespace JVBase\integrations; use JetBrains\PhpStorm\NoReturn; use WP_Error; if (!defined('ABSPATH')) { exit; } trait OAuth { use _Base, Admin; protected bool $hasOAuth = true; /** * Child classes must set this (e.g. 'https://api.example.com/v1/') */ protected ?string $baseOAuthUrl = null; protected ?string $oauthRedirectUrl = null; protected int $oauthTTL = 300; protected array $oathScopes = []; abstract protected function getOAuthRequestHeaders(): array; abstract protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array; abstract protected function refreshAccessToken(OAuthCredentials $credentials): array; function addOAuthActions():void { $actions = [ [ 'name' => 'Connect to Service', 'label' => 'Connect', 'icon' => 'plugs-connected', 'action' => [$this, 'handleOAuthConnect'] ], [ 'name' => 'Disconnect from Service', 'icon' => 'plugs-disconnected', 'action' => [$this, 'handleOAuthDisconnect'] ], [ 'name' => 'Refresh Token', 'icon' => 'arrows-clockwise', 'action' => [$this, 'handleOAuthRefreshToken'] ] ]; foreach ($actions as $a) { $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null); } } protected function registerOAuthCallbacks():void { 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'] ); } protected function getOAuthRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array { $key = [$this->service_name, $this->userID, $endpoint, $data]; if (!$force) { $cached = $this->cache->get($key); if ($cached) { return $cached; } } $response = $this->makeOAuthRequest('GET', $endpoint, $data); if ($response && !is_wp_error($response)) { $ttl = is_int($ttl) ? $ttl : $this->oauthTTL; $this->cache->set($key, $response, $ttl); } return $response; } protected function postOAuthRequest(string $endpoint, array $data = []): array { return $this->makeOAuthRequest('POST', $endpoint, $data); } protected function putOAuthRequest(string $endpoint, array $data = []): array { return $this->makeOAuthRequest('PUT', $endpoint, $data); } protected function deleteOAuthRequest(string $endpoint, array $data = []): array { return $this->makeOAuthRequest('DELETE', $endpoint, $data); } protected function makeOAuthRequest(string $method, string $endpoint, array $data = []): array { if (is_null($this->baseOAuthUrl)) { error_log('Can not make request. No $baseOAuthUrl set for '.$this->service_name); return []; } if ($this->oauthLimiter && !$this->awaitOAuthRateLimit($endpoint)) { $error = new WP_Error('rate_limit_exceeded', "Rate limit exceeded for {$endpoint}"); return $this->handleOAuthResponse($error, $method, $endpoint); } $credentials = $this->ensureFreshToken(); if (!$credentials->isValid()) { $error = new WP_Error('invalid_credentials', "Missing or invalid OAuth credentials for {$this->service_name}"); return $this->handleOAuthResponse($error, $method, $endpoint); } if (!empty($this->requiredScopes) && !$credentials->hasScopes($this->requiredScopes)) { $error = new WP_Error('insufficient_scope', "Missing required scopes for {$this->service_name}"); return $this->handleOAuthResponse($error, $method, $endpoint); } $url = rtrim($this->baseOAuthUrl, '/') . '/' . ltrim($endpoint, '/'); $args = [ 'method' => $method, 'headers' => $this->getOAuthRequestHeaders(), 'timeout' => 15, ]; if (!empty($data)) { if (in_array($method, ['GET', 'DELETE'], true)) { $url = add_query_arg($data, $url); } else { $args['body'] = wp_json_encode($data); } } $response = wp_remote_request($url, $args); $this->oauthLimiter?->recordRequest($endpoint); return $this->handleOAuthResponse($response, $method, $endpoint); } /** * Checks stored credentials, refreshes via refreshAccessToken() if expired, * and persists the refreshed credentials back to Auth */ protected function ensureFreshToken(): OAuthCredentials { $auth = Auth::getInstance(); $credentials = new OAuthCredentials($auth->getCredentials($this->service_name, $this->userID)); if (!$credentials->isValid() || !$credentials->isExpired()) { return $credentials; } $refreshed = new OAuthCredentials($this->refreshAccessToken($credentials)); if ($refreshed->isValid()) { $auth->storeCredentials($this->service_name, $refreshed->toArray(), $this->userID); return $refreshed; } return $credentials; } public function hasValidOAuth():bool { $auth = Auth::getInstance(); $credentials = new OAuthCredentials($auth->getCredentials($this->service_name, $this->userID)); return $credentials->isValid(); } protected function awaitOAuthRateLimit(string $endpoint, int $maxAttempts = 3): bool { $attempts = 0; if (is_null($this->oauthLimiter)) { return false; } while (!$this->oauthLimiter->checkRateLimit($endpoint)) { $attempts++; if ($attempts >= $maxAttempts) { return false; } usleep(random_int(250_000, 750_000)); } return true; } /*************************************************************** * ACTIONS CALLBACKS ***************************************************************/ #[NoReturn] public function handleAjaxResponse():void { $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; } protected function renderOAuthConnectionForm():string { $credentials = $this->loadCredentials(); $hasCredentials = $this->hasValidOAuth(); $returnURL = $this->getReturnURL(); if ($hasCredentials) { return sprintf( '<div class="oauth-connect"> <h3>Connect to your %s account</h3> <a href="%s" class="btn jvb-oauth-connect" data-service="%s">%s Authorize Connection</a>', $this->title, $this->getOAuthURL($returnURL), esc_attr($this->service_name), $this->outputIcon($this->icon) ); } $form = sprintf( '<details open> <summary>Connected Account <div class="connection-status connected"> <span class="status-indicator">●</span> <span>Connected</span> </div> </summary> <span class="label">OAuth Redirect URL:</span> <code>%s</code>', $this->getRedirectUri() ); if (!empty($credentials['updated_at'])) { $form .= sprintf( '<div class="oauth-meta"><small>Token expires: %s</small></div>', array_key_exists('expires_at', $credentials) ? human_time_diff($credentials['expires_at']) : 'Never' ); } // if (method_exists($this, 'renderOAuthConnectedOptions')) { $form .= $this->renderOAuthConnectedOptions(); } $form .= '</details'; return $form; } /** * @return string Outputs additional information after the basic OAuth connection */ protected function renderOAuthConnectedOptions():string { return ''; } public function getOAuthURL(?string $return_url = null):string { if (!$this->hasOAuth) { return ''; } $credentials = $this->loadCredentials(); if (empty($credentials['authorize'])) { $this->logError('getOAuthUrl', 'OAuth authorize URL not configured'); return ''; } $params = [ 'client_id' => $credentials['client_id']??$credentials['application_id']??$credentials['app_id']??'', 'redirect_url' => $this->getRedirectUri(), 'response_type' => 'code', 'scope' => implode(' ', $credentials['scopes']??[]) ]; $state_key = wp_generate_password(32, false); $user_id = $this->userID??0; //Store state data in transient, expiring in 10 minutes set_transient( BASE.'oauth_state_'.$state_key, [ 'service' => $this->service_name, 'user_id' => $user_id, 'created' => time() ], 600 ); $state_parts = [ $state_key, $user_id ]; $state_parts[] = base64_encode(is_null($return_url) ? $this->getReturnURL() : $return_url); $params['state'] = implode('|', $state_parts); //Allow extensions to modify params if (method_exists($this, 'addOAuthParams')) { $params = $this->addOAuthParams($params); } return $credentials['authorize'] . '?' . http_build_query($params); } protected function getRedirectUri():string { if ($this->hasOAuth) { return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback'); } return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name); } } inc/integrations/OAuthCredentials.php
New file @@ -0,0 +1,47 @@ <?php namespace JVBase\integrations; if (!defined('ABSPATH')) { exit; } class OAuthCredentials { public string $access_token; public int $expires_at; public ?string $refresh_token; public array $scopes; public function __construct(array $data) { $this->access_token = $data['access_token'] ?? ''; $this->expires_at = (int)($data['expires_at'] ?? 0); $this->refresh_token = $data['refresh_token'] ?? null; $this->scopes = $data['scopes'] ?? []; } public function isValid(): bool { return !empty($this->access_token) && $this->expires_at > 0; } public function isExpired(int $buffer = 60): bool { return $this->expires_at <= (time() + $buffer); } public function hasScopes(array $required): bool { return empty(array_diff($required, $this->scopes)); } public function toArray(): array { return [ 'access_token' => $this->access_token, 'expires_at' => $this->expires_at, 'refresh_token' => $this->refresh_token, 'scopes' => $this->scopes, ]; } } inc/integrations/RateLimits.php
New file @@ -0,0 +1,103 @@ <?php namespace JVBase\integrations; if (!defined('ABSPATH')) { exit; } class RateLimits { protected array $history = []; protected int $maxHistory = 100; protected array $limits = [ 'per_second' => 2, 'per_minute' => 30, 'per_hour' => 1000 ]; protected string $key; public function __construct(string $key, array $limits = []) { $this->key = sanitize_title($key); //Allow custom limits foreach ($limits as $interval => $limit) { $key = match($interval) { 'second', 'per_second','s' => 'per_second', 'minute', 'per_minute', 'm' => 'per_minute', 'hour', 'per_hour', 'h' => 'per_hour', default => false }; if (!$key) { error_log('Attempted to add custom limit: '.$interval.' for key '.$key); continue; } $this->limits[$key] = (int)$limit; } $this->getHistory(); add_action('shutdown', [$this, 'saveHistory']); } protected function getHistory():void { $stored = get_transient(BASE.$this->key.'_limits'); if (!$stored) { $stored = []; } $this->history = $stored; } public function recordRequest(string $endpoint = 'base'):void { if (!array_key_exists($endpoint, $this->history)) { $this->history[$endpoint] = []; } $this->history[$endpoint][] = time(); $this->checkHistorySize(); } public function saveHistory():void { set_transient(BASE.$this->key.'_limits', $this->history); } protected function checkHistorySize():void { foreach ($this->history as $endpoint => $history) { if (count($history) > $this->maxHistory) { $this->history[$endpoint] = array_slice($this->history[$endpoint], -$this->maxHistory); } } } public function checkRateLimit(string $endpoint = 'base'):bool { if (!array_key_exists($endpoint, $this->history)) { return true; } $now = time(); $this->cleanHistory($now); $counts = [ 'per_second' => 0, 'per_minute' => 0, 'per_hour' => count($this->history) ]; foreach ($this->history[$endpoint]??[] as $timestamp) { if ($now - $timestamp <=1) $counts['per_second']++; if ($now - $timestamp <=60) $counts['per_minute']++; } foreach ($this->limits as $interval => $limit) { if ($counts[$interval] >= $limit) return false; } return true; } protected function cleanHistory($now):void { $this->history = array_map(function ($endpointHistory) use ($now) { return array_filter($endpointHistory, function($timestamp) use ($now) { return ($now - $timestamp) < 3600; //Keep last hour }); }, $this->history); } } inc/integrations/Requests.php
New file @@ -0,0 +1,114 @@ <?php namespace JVBase\integrations; if (!defined('ABSPATH')) { exit; } trait Requests { protected bool $hasRequests = true; /** * Child classes must set this (e.g. 'https://api.example.com/v1/') */ protected ?string $baseUrl = null; protected int $ttl = 300; //5 minutes default /** * Child classes define their auth headers (Bearer token, API key, etc.) */ abstract protected function getRequestHeaders(): array; /** * Child classes inspect the wp_remote_request response, * decode it, and handle/log errors as needed */ abstract protected function handleResponse(array|\WP_Error $response, string $method, string $endpoint): array; public function getRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array { $key = [$this->service_name, $this->userID, $endpoint, $data]; if (!$force) { $cached = $this->cache->get($key); if ($cached) { return $cached; } } $response = $this->makeRequest('GET', $endpoint, $data); if ($response && !is_wp_error($response)) { $ttl = is_int($ttl) ? $ttl : $this->ttl; $this->cache->set($key, $response, $ttl); } return $response; } public function postRequest(string $endpoint, array $data = []): array { return $this->makeRequest('POST', $endpoint, $data); } public function putRequest(string $endpoint, array $data = []): array { return $this->makeRequest('PUT', $endpoint, $data); } public function deleteRequest(string $endpoint, array $data = []): array { return $this->makeRequest('DELETE', $endpoint, $data); } protected function makeRequest(string $method, string $endpoint, array $data = []): array { if (is_null($this->baseUrl)) { error_log('Can not make request. No $baseUrl set for '.$this->service_name); return []; } if ($this->requestLimiter && !$this->awaitRateLimit($endpoint)) { $error = new \WP_Error('rate_limit_exceeded', "Rate limit exceeded for {$endpoint}"); return $this->handleResponse($error, $method, $endpoint); } $url = rtrim($this->baseUrl, '/') . '/' . ltrim($endpoint, '/'); $args = [ 'method' => $method, 'headers' => $this->getRequestHeaders(), 'timeout' => 15, ]; if (!empty($data)) { if (in_array($method, ['GET', 'DELETE'], true)) { $url = add_query_arg($data, $url); } else { $args['body'] = wp_json_encode($data); } } $response = wp_remote_request($url, $args); $this->requestLimiter?->recordRequest($endpoint); return $this->handleResponse($response, $method, $endpoint); } /** * Waits (with jitter) for the rate limit to clear, up to a few attempts. * Returns false if still limited after max attempts. */ protected function awaitRateLimit(string $endpoint, int $maxAttempts = 3): bool { $attempts = 0; while (!$this->requestLimiter->checkRateLimit($endpoint)) { $attempts++; if ($attempts >= $maxAttempts) { return false; } usleep(random_int(250_000, 750_000)); // 250-750ms jitter } return true; } } inc/integrations/SyncDefaults.php
New file @@ -0,0 +1,13 @@ <?php namespace JVBase\integrations; if (!defined('ABSPATH')) { exit; } /** * Handles the getting and setting of default values for service fields. */ trait SyncDefaults { } inc/integrations/SyncFrom.php
New file @@ -0,0 +1,41 @@ <?php namespace JVBase\integrations; use Exception; if (!defined('ABSPATH')) { exit; } trait SyncFrom { use SyncHelpers; /** * @param array $itemData * @return bool * @throws Exception */ protected function createItemFromService(array $itemData):bool { throw new Exception('createItemFromService must be implemented by child class '.$this->service_name); } /** * @param array $itemData * @return bool * @throws Exception */ protected function updateItemFromService(array $itemData):bool { throw new Exception('updateItemFromService must be implemented by child class '.$this->service_name); } /** * @param array $itemData * @return bool * @throws Exception */ protected function deleteItemFromService(array $itemData):bool { throw new Exception('deleteItemFromService must be implemented by child class '.$this->service_name); } } inc/integrations/SyncHelpers.php
New file @@ -0,0 +1,144 @@ <?php namespace JVBase\integrations; use JVBase\meta\Meta; use WP_Query; if (!defined('ABSPATH')) { exit; } trait SyncHelpers { use _Base; /** * Queue types * These types match with IntegrationExecutor */ protected static string $syncTo; protected static string $deleteFrom; protected static string $syncFrom; protected static string $syncCustomer; protected static string $import; protected function setQueueTypes():void { self::$syncTo = $this->service_name.'_sync_to'; self::$deleteFrom = $this->service_name.'_delete_from'; self::$syncFrom = $this->service_name.'_sync_from'; //TODO: What is the difference between sync_from and import? self::$import = $this->service_name.'import_from'; } public function getServiceItemID(int $itemID, string $type = 'post'):string { return match($type) { 'post' => Meta::forPost($itemID)->get("_{$this->service_name}_item_id"), 'term','taxonomy' => Meta::forTerm($itemID)->get("_{$this->service_name}_item_id"), 'user' => Meta::forUser($itemID)->get("_{$this->service_name}_item_id"), default => '' }; } protected function updateItemStatus(int|array $items, string $status = 'pending', string $type = 'post'):void { if (!is_array($items)) { $items = [$items]; } foreach ($items as $ID) { match ($type) { 'post' => Meta::forPost($ID)->set('_'.$this->service_name.'_sync_status', $status), 'term' => Meta::forTerm($ID)->set('_'.$this->service_name.'_sync_status', $status), 'user' => Meta::forUser($ID)->set('_'.$this->service_name.'_sync_status', $status), default => false }; } } public function findWPItem(string $integrationID, ?string $type = null):array|false { if (empty($integrationID)) { error_log('Integrations::findWPItem No Integration ID received.'); return false; } $types = ['post', 'term', 'user']; if (!is_null($type) && !in_array($type, $types)) { error_log('Integrations::findWPItem invalid type sent: '.$type.'; defaulting to checking all'); $type = null; } if (!is_null($type)) { return $this->queryWPType($integrationID, $type); } else { foreach ($types as $type) { $check = $this->queryWPType($integrationID, $type); if ($check) { return $check; } } } return false; } /** * @param string $integrationID * @param string|null $type * @return array|false if success, an array of $ID and $type */ public function queryWPType(string $integrationID, ?string $type = null):array|false { if (!in_array($type, ['post', 'term', 'user'])) { return $this->findWPItem($integrationID); } return match($type) { 'post' => $this->findWPPost($integrationID), 'term' => $this->findWPTerm($integrationID), 'user' => $this->findWPUser($integrationID), default => false }; } public function findWPPost(string $integrationID):array|false { if (empty($integrationID)) { return false; } $get = new WP_Query([ 'meta_query' => [ 'key' => BASE."_{$this->service_name}_item_id", 'value' => $integrationID, ], 'fields' => 'ids', 'posts_per_page' => 1, ]); return $get->have_posts() && !empty($get->posts) ? [$get->posts[0], 'post'] : false; } public function findWPTerm(string $integrationID):array|false { if (empty($integrationID)) { return false; } $get = get_terms([ 'meta_query' => [ 'key' => BASE."_{$this->service_name}_item_id", 'value' => $integrationID, ], 'fields' => 'ids', 'hide_empty'=> true, 'number' => 1 ]); return is_wp_error($get) || empty($get) ? false : [$get[0], 'term']; } public function findWPUser(string $integrationID):array|false { if (empty($integrationID)) { return false; } $get = get_users([ 'meta_query' => [ 'key' => BASE."_{$this->service_name}_item_id", 'value' => $integrationID, ], 'fields' => 'ids', 'number' => 1, ]); return is_wp_error($get) || empty($get) ? false : [$get[0], 'user']; } } inc/integrations/SyncTo.php
New file @@ -0,0 +1,310 @@ <?php namespace JVBase\integrations; use Exception; use WP_Error; if (!defined('ABSPATH')) { exit; } trait SyncTo { use SyncHelpers; /** * Must be defined according to how each service needs it to be * @param int $itemID * @param string $type * @return array * @throws Exception */ protected function formatForService(int $itemID, string $type = 'post'):array { throw new Exception('formatForService must be implemented by child class'); } /** * Defaults to an array of formatted items. Can be overridden in child classes * @param array $itemIDs * @param string $type * @return array */ protected function formatItems(array $itemIDs, string $type):array { $items = []; foreach ($itemIDs as $ID) { try { $items[] = $this->formatForService($ID, $type); } catch (Exception $e){ $this->logError('Could not format Item for service',[ 'itemID' => $ID, 'type' => $type, 'message' => $e->getMessage(), ]); } } return $items; } /*************************************************************** * Item creation ***************************************************************/ public function createBatchToService($data):array { $type = $data['type']??'post'; //Check if any of the submitted ids are already created $created = array_filter($data['items'], function($ID) use ($type) { return !empty($this->getServiceItemID($ID, $type)); }); $updated = []; if (!empty($created)) { $updateData = $data; $updateData['items'] = $created; $updated = $this->updateBatchToService($data); //remove any updated items from the original items to process $data['items'] = array_filter($data['items'], function ($ID) use ($created) { return !in_array($ID, $created); }); } $items = $this->formatItems($data['items'], $type); if (empty($items)) { return $this->noCreatedItems($updated); } $response = [ 'outcome' => 'failed', 'result' => [ 'message' => 'No result' ] ]; if ($this->hasBatchCreate) { $response = $this->sendBatchCreate($items); if (!is_wp_error($response)) { $result = $this->processBatchCreateResponse($data, $response); } else { $this->logError('Batch create failed',[ 'method' => 'createBatchToService', 'item_ids' => $data['items'], 'error' => $response ]); $this->updateItemStatus($data['items'], 'error'); $result = [ 'outcome' => 'failed_permanent', 'result' => 'Could not update items' ]; } } else { $success = $errors = []; foreach ($items as $item) { $itemResult = $this->createOne($item); if (!is_wp_error($itemResult)) { $success[] = $itemResult; } else { $errors[] = $itemResult; } } $result = [ 'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'), 'result' => [ 'success' => $success, 'errors' => $errors ] ]; } return array_merge($result, $updated); } /** * To be implemented by integration extension. Updates a single item * @param array $item * @return array|WP_Error */ protected function createOne(array $item):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOne'); } /** * Overridden by child classes * @param array $items * @return array|WP_Error */ protected function sendBatchCreate(array $items):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchCreate'); } /** * To be implemented by extensions * @param array $data * @param array $response * @return array */ protected function processBatchCreateResponse(array $data, array $response):array { return [ 'outcome' => 'failed', 'result' => [ 'message' => $this->service_name.' should implement processBatchCreateResponse.' ] ]; } /***************************************************************** * ITEM UPDATING *****************************************************************/ public function updateBatchToService(array $data):array { $type = $data['type']??'post'; $newlyCreated = []; if (!$this->canCreateOnUpdate) { $created = array_filter($data['items'], function($ID) use ($type) { return !empty($this->getServiceItemID($ID, $type)); }); //Test to see if we have any that haven't been created yet. //For some services, items may have to be created before they can be updated if (count($created) !== count($data['items'])) { $notCreated = array_filter($data['items'], function($ID) use ($type) { return empty($this->getServiceItemID($ID, $type)); }); $newData = $data; $newData['items'] = $notCreated; $newlyCreated = $this->createBatchToService($newData); } // If we don't have any that are created, just send the noUpdatedItems response, with any newly created items added if (empty($created)) { return $this->noUpdatedItems($newlyCreated); } $data['items'] = $created; } $items = $this->formatItems($data['items'], $type); if (empty($items)) { return $this->noUpdatedItems($newlyCreated); } if ($this->hasBatchUpdate) { $response = $this->sendBatchUpdate($items); if (!is_wp_error($response)) { $result = $this->processBatchUpdateResponse($data, $response); } else { $this->logError('Batch update failed',[ 'method' => 'updateBatchToService', 'item_ids' => $data['items'], 'error' => $response ]); $this->updateItemStatus($data['items'], 'error'); $result = [ 'outcome' => 'failed_permanent', 'result' => 'Could not update items' ]; } } else { $success = $errors = []; //Does not have batch update, manually update each one foreach ($items as $item) { $itemResult = $this->updateOne($item); if (!is_wp_error($itemResult)) { $success[] = $itemResult; } else { $errors[] = $itemResult; } } $result = [ 'outcome' => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'), 'result' => [ 'success' => $success, 'errors' => $errors ] ]; } return array_merge($result, $newlyCreated); } /** * To be implemented by integration extension. Updates a single item * @param array $item * @return array|WP_Error */ protected function updateOne(array $item):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOne method'); } /** * Overridden by child classes * @param array $items * @return array|WP_Error */ protected function sendBatchUpdate(array $items):array|WP_Error { return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchUpdate'); } /** * To be implemented by extensions * @param array $data * @param array $response * @return array */ protected function processBatchUpdateResponse(array $data, array $response):array { return [ 'outcome' => 'failed', 'result' => [ 'message' => $this->service_name.' should implement processBatchUpdateResponse.' ] ]; } /***************************************************************** * UTILITY *****************************************************************/ protected function noUpdatedItems(array $created):array { $result = [ 'outcome' => 'success', 'result' => [ 'message' => 'No items to update', 'updated' => [], 'errors' => [], ] ]; if (!empty($created)) { error_log('Result before: '.print_r($result, true)); $result = array_merge($result, $created); error_log('Result after merge: '.print_r($result, true)); } return $result; } protected function noCreatedItems(array $updated):array { $result = [ 'outcome' => 'success', 'result' => [ 'message' => 'No items to create', 'created' => [], 'errors' => [], ] ]; if (!empty($updated)) { $result = array_merge($result, $updated); } return $result; } } inc/integrations/UserConnection.php
New file @@ -0,0 +1,62 @@ <?php namespace JVBase\integrations; use JVBase\meta\Meta; use JVBase\registrar\Registrar; if (!defined('ABSPATH')) { exit; } trait UserConnection { protected function determineUser():void { if (current_user_can('manage_options')) { $this->switchUser(); } $this->switchUser(get_current_user_id()); } public function switchUser(?int $userID = null):bool { if (!$this->userCanConnect($userID)) { return false; } $this->clearUser(); $this->userID = $userID; $this->initializeRateLimiters(); return true; } protected function clearUser():void { $this->userID = false; $this->credentials = []; if (property_exists($this, 'rateLimiter')) { $this->requestLimiter?->saveHistory(); $this->requestLimiter = null; } if (property_exists($this, 'oathLimiter')) { $this->oauthLimiter?->saveHistory(); $this->oauthLimiter = null; } } public function userCanConnect(?int $userID):bool { if (current_user_can('manage_options')) { return true; } $user = get_userdata($userID); if (!$user || is_wp_error($user)) { return false; } $role = jvbUserRole($userID); $registrar = Registrar::getInstance($role); if (!$registrar || !$registrar->hasIntegration($this->service_name)) { return false; } return (bool) Meta::forUser($userID)->get('integration_'.$this->service_name.'_connected'); } } inc/integrations/Webhooks.php
New file @@ -0,0 +1,125 @@ <?php namespace JVBase\integrations; use Exception; use WP_REST_Request; use WP_REST_Response; if (!defined('ABSPATH')) { exit; } trait Webhooks { use _Base; protected bool $hasWebhooks = true; abstract protected function validateWebhook(array $payload):bool; abstract protected function processWebhook(array $payload):bool; abstract protected function extractWebhookId(array $payload):string; /** * 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); } public function handleWebhook(array $payload):bool { error_log('[Integrations]::handleWebhook for '.$this->service_name.': '.print_r($payload, true)); if (!$this->validateWebhook($payload)) { $this->logError('handleWebhook', 'Webhook validation failed.'); return false; } //Check for duplicate processing if ($this->isWebhookProcessed($payload)) { return true; } try { $result = $this->processWebhook($payload); if ($result) { $this->markWebhookProcessed($payload); } else { $this->logError('handleWebhook', 'Webhook processing returned false.'); } return $result; } catch (Exception $e) { $this->logError('handleWebhook', 'Webhook processing failed', [ 'error' => $e->getMessage(), 'webhook_id' => $this->extractWebhookId($payload), 'trace' => $e->getTraceAsString() ]); Auth::getInstance()->markUnhealthy($this->service_name, 'oauth', $this->userID); return false; } } protected function renderWebhookUrl(): string { if (!$this->hasWebhooks || !$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) ); } protected function isWebhookProcessed(array $payload):bool { $id = $this->extractWebhookId($payload); $key = BASE."webhook_processed_{$this->service_name}_{$id}"; return get_transient($key)!==false; } protected function markWebhookProcessed(array $payload):void { $id = $this->extractWebhookId($payload); if (!empty($id)) { $key = BASE."webhook_processed_{$this->service_name}_{$id}"; set_transient($key, true, DAY_IN_SECONDS); } } } inc/integrations/_Base.php
New file @@ -0,0 +1,110 @@ <?php namespace JVBase\integrations; use JVBase\managers\Cache; use JVBase\managers\IconsManager; if (!defined('ABSPATH')) { exit; } trait _Base { /** * @var string Unique identifier for this service (ex: square, facebook) */ protected string $service_name; /** * @var int|null The user context. If it is an OAuth integration, users may have their own connections, accessible via their user id */ protected ?int $userID = null; /** * @var string Human-readable service name (ex: 'Google My Business', 'Square', etc) */ public string $title; /** * @var string Phosphoricons icon slug */ public string $icon = ''; /** * @var string API version string (ex: 'v2', '2024-01-01') */ protected string $apiVersion; protected Cache $cache; //Use Trait "Requests" to set this protected bool $hasRequests = false; protected ?RateLimits $requestLimiter = null; //Use Trait "OAuth" to set this protected ?RateLimits $oauthLimiter = null; protected bool $hasOAuth = false; //Use trait "Webhooks" to set this true protected bool $hasWebhooks = false; /** * @var array Each item in the array becomes a li element in the ordered list of instructions */ protected array $instructions = []; /** * @var array The required fields for the credentials. Typically a site_key/secret_key or equivalent. */ protected array $credentialFields = []; /** * @var array The required fields for OAuth connection */ protected array $oauthFields = []; /** * @var array Optional fields that just tweak functionality */ protected array $optionalFields = []; protected bool $supportsWebp = true; /** * @var bool Flag to indicate there are extra options that warrant a dedicated options page for this service * Usually used for default field values for syncable services */ public bool $hasExtraOptions = false; /** * Check if integration is properly configured */ public function isSetUp(): bool { return !empty($this->loadCredentials()); } public function loadCredentials():array { return Auth::getInstance()->getCredentials($this->service_name, $this->userID); } protected function logError(string $method, string $message, array $context = []):void { $context['service'] = $this->service_name; JVB()->error()->log('['.self::class.']'.$method, $message, $context); } protected function getReturnURL():string { //If we are on the main admin page, we want to go back to our Integrations page on admin //If we are on the custom dashboard, we want to go back to that dashboard page return is_admin() ? admin_url('admin.php?page=jvb-integrations') : get_home_url(null, '/dash/integrations/'); } protected function determineOptionPage():string { return is_admin() ? admin_url('admin.php?page=jvb-integrations-'.$this->service_name) : get_home_url(null, '/dash/integrations/'.$this->service_name.'/'); } protected function outputIcon(string $icon):string { if (is_admin()) { $icons = IconsManager::for('admin'); return $icons->getRawSvg($icon); } return jvbDashIcon($icon); } } inc/integrations/_setup.php
@@ -1,47 +1,52 @@ <?php use JVBase\base\Site; require(JVB_DIR . '/inc/integrations/Admin.php'); require(JVB_DIR . '/inc/integrations/Ajax.php'); require(JVB_DIR . '/inc/integrations/Auth.php'); require(JVB_DIR . '/inc/integrations/OAuth.php'); require(JVB_DIR . '/inc/integrations/RateLimits.php'); require(JVB_DIR . '/inc/integrations/Requests.php'); require(JVB_DIR . '/inc/integrations/Integrations.php'); require(JVB_DIR . '/inc/integrations/CredentialsManager.php'); if (Site::hasIntegration('maps')) { require(JVB_DIR . '/inc/integrations/GoogleMaps.php'); require(JVB_DIR . '/inc/integrations/services/GoogleMaps.php'); } if (Site::hasIntegration('umami')) { require(JVB_DIR . '/inc/integrations/Umami.php'); require(JVB_DIR . '/inc/integrations/services/Umami.php'); } if (Site::hasIntegration('cloudflare')) { require(JVB_DIR . '/inc/integrations/Cloudflare.php'); require(JVB_DIR . '/inc/integrations/services/Cloudflare.php'); } if (Site::hasIntegration('square')) { require(JVB_DIR . '/inc/integrations/Square.php'); require(JVB_DIR . '/inc/integrations/services/Square.php'); } if (Site::hasIntegration('facebook')) { require(JVB_DIR . '/inc/integrations/Facebook.php'); require(JVB_DIR . '/inc/integrations/services/Facebook.php'); } if (Site::hasIntegration('instagram')) { if (!Site::hasIntegration('facebook')) { require(JVB_DIR . '/inc/integrations/Facebook.php'); require(JVB_DIR . '/inc/integrations/services/Facebook.php'); } require(JVB_DIR . '/inc/integrations/Instagram.php'); require(JVB_DIR . '/inc/integrations/services/Instagram.php'); } if (Site::hasIntegration('bluesky')) { require(JVB_DIR . '/inc/integrations/BlueSky.php'); require(JVB_DIR . '/inc/integrations/services/BlueSky.php'); } if (Site::hasIntegration('gmb')) { require(JVB_DIR . '/inc/integrations/GoogleMyBusiness.php'); require(JVB_DIR . '/inc/integrations/services/GoogleMyBusiness.php'); } if (Site::hasIntegration('postmark')) { require(JVB_DIR . '/inc/integrations/PostMark.php'); require(JVB_DIR . '/inc/integrations/services/PostMark.php'); } if (Site::hasIntegration('helcim')) { require(JVB_DIR . '/inc/integrations/Helcim.php'); require(JVB_DIR . '/inc/integrations/services/Helcim.php'); } inc/integrations/services/BlueSky.php
File was renamed from inc/integrations/BlueSky.php @@ -1,9 +1,7 @@ <?php namespace JVBase\integrations; namespace JVBase\integrations\services; use Exception; use WP_Error; use JVBase\integrations\Integrations; if (!defined('ABSPATH')) { exit; } @@ -361,7 +359,7 @@ public function disconnectUser(int $user_id): bool { $key = "user_{$user_id}_{$this->service_name}"; $credentials_manager = CredentialsManager::getInstance(); $credentials_manager = Auth::getInstance(); return $credentials_manager->deleteCredentials($key); } inc/integrations/services/Cloudflare.php
File was renamed from inc/integrations/Cloudflare.php @@ -1,7 +1,7 @@ <?php namespace JVBase\integrations; use WP_Error; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; if (!defined('ABSPATH')) { exit; } inc/integrations/services/Facebook.php
File was renamed from inc/integrations/Facebook.php @@ -3,12 +3,12 @@ * Facebook Integration * Handles Facebook posts, page information, events, and OAuth authentication */ namespace JVBase\integrations; use Exception; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; use JVBase\meta\Meta; use WP_Error; use WP_Post; use WP_Error; if (!defined('ABSPATH')) { exit; @@ -127,8 +127,9 @@ $this->defaults = [ ]; $this->handleWebhooks = true; parent::__construct($userID); $this->hasWebhooks = true; parent::__construct(); $this->actions = array_merge($this->actions, [ 'sync_pages' => 'getUserPages' inc/integrations/services/GoogleMaps.php
File was renamed from inc/integrations/GoogleMaps.php @@ -3,10 +3,10 @@ * Google Maps Integration * File: /inc/integrations/GoogleMapsIntegration.php */ namespace JVBase\integrations; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; use JVBase\registrar\Registrar; use WP_Error; if (!defined('ABSPATH')) { exit; inc/integrations/services/GoogleMyBusiness.php
File was renamed from inc/integrations/GoogleMyBusiness.php @@ -1,8 +1,8 @@ <?php namespace JVBase\integrations; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; use JVBase\meta\Meta; use WP_Error; if (!defined('ABSPATH')) { exit; } @@ -125,7 +125,7 @@ 'Use the "Authorize" button below to complete OAuth flow' ]; parent::__construct($userID); parent::__construct(); $this->actions = array_merge( $this->actions, inc/integrations/services/Helcim.php
File was renamed from inc/integrations/Helcim.php @@ -1,13 +1,11 @@ <?php namespace JVBase\integrations; use Exception; use JVBase\meta\Meta; use WP_Error; use WP_Post; use JVBase\ui\Checkout; use JVBase\managers\queue\TypeConfig; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; use JVBase\managers\queue\executors\IntegrationExecutor; use JVBase\managers\queue\TypeConfig; use JVBase\meta\Meta; use JVBase\ui\Checkout; if (!defined('ABSPATH')) { exit; @@ -103,9 +101,9 @@ 'delete' => false, ]; $this->handleWebhooks = false; $this->hasWebhooks = false; parent::__construct($userID); parent::__construct(); // Helcim-specific actions (processAction dispatches these) $this->actions = array_merge($this->actions, [ inc/integrations/services/Instagram.php
File was renamed from inc/integrations/Instagram.php @@ -3,14 +3,10 @@ * Instagram Integration * File: /inc/integrations/Instagram.php */ namespace JVBase\integrations; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; use JVBase\meta\Meta; use WP_Error; use WP_REST_Request; use WP_REST_Response; use Exception; if (!defined('ABSPATH')) { exit; @@ -99,7 +95,7 @@ $this->defaults = [ ]; $this->handleWebhooks = true; $this->hasWebhooks = true; parent::__construct($userID); $this->actions = array_merge( @@ -192,8 +188,8 @@ } public function getCredentials():array { parent::getCredentials(); if (empty($this->credentials)) { $credentials = parent::getCredentials(); if (empty($credentials)) { $facebook = JVB()->connect('facebook'); if (!$facebook || !$facebook->isSetUp()) { @@ -202,12 +198,12 @@ $fbCredentials = $facebook->getCredentials(); $this->credentials = [ return [ 'client_id' => $fbCredentials['client_id'], 'client_secret' => $fbCredentials['client_secret'], ]; } return $this->credentials; return $credentials; } private function switchInstagramAccount(array $data): WP_Error|array inc/integrations/services/PostMark.php
File was renamed from inc/integrations/PostMark.php @@ -1,8 +1,7 @@ <?php namespace JVBase\integrations; use WP_Error; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; if (!defined('ABSPATH')) { exit; } @@ -117,7 +116,7 @@ ] ]; parent::__construct($userID); parent::__construct(); // Hook into WordPress mail system if integration is healthy if ($this->is_healthy && $this->isSetUp()) { inc/integrations/services/Square.php
File was renamed from inc/integrations/Square.php @@ -1,18 +1,19 @@ <?php namespace JVBase\integrations; namespace JVBase\integrations\services; use Exception; use JVBase\integrations\Integrations; use JVBase\managers\queue\executors\IntegrationExecutor; use JVBase\managers\queue\TypeConfig; use JVBase\meta\Form; use JVBase\meta\Meta; use Exception; use JVBase\registrar\Fields; use JVBase\registrar\Posts; use JVBase\registrar\Registrar; use JVBase\ui\Checkout; use JVBase\ui\CRUDSkeleton; use WP_Error; use JVBase\ui\Checkout; use JVBase\managers\queue\TypeConfig; use JVBase\managers\queue\executors\IntegrationExecutor; use WP_Post; use WP_Query; use WP_User; if (!defined('ABSPATH')) { exit; @@ -86,16 +87,14 @@ protected string $locationId = ''; protected array $locations = []; protected static array $instances = []; protected static Square $instance; public static function getInstance(?int $userID = null):self public static function getInstance():self { $key = is_null($userID) ? 'base' : $userID; if (!array_key_exists($key, self::$instances)) { self::$instances[$key] = new self($userID); if (!isset(self::$instance)) { self::$instance = new self(); } return self::$instances[$key]; return self::$instance; } protected function __construct(?int $userID = null) @@ -190,9 +189,9 @@ 'delete' => true ]; $this->supportsWebp = false; $this->handleWebhooks = true; $this->hasWebhooks = true; parent::__construct($userID); parent::__construct(); // Add Square-specific actions $this->actions = array_merge( @@ -858,6 +857,7 @@ protected function registerAdditionalHooks(): void { if (!$this->isSetUp()) { error_log('Square is not setup'); return; } @@ -932,7 +932,7 @@ /** * Handle post save for Square sync */ protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void { error_log('==== [Square]::handleTheSavePost ===='); $this->queueOperation(self::$syncTo, [ @@ -1113,7 +1113,7 @@ if (!in_array($post_id, $success)) { $found_in_errors = false; foreach ($errors as $error) { if (strpos($error, "Post $post_id") !== false) { if (str_contains($error, "Post $post_id")) { $found_in_errors = true; break; } @@ -1542,7 +1542,7 @@ } // Set user role (assuming you have a customer role defined) $user = new \WP_User($user_id); $user = new WP_User($user_id); $user->set_role(BASE.'foodie'); // Generate password reset key @@ -1576,7 +1576,7 @@ /** * Send welcome email with password setup */ private function sendWelcomeEmail(\WP_User $user, string $reset_key): void private function sendWelcomeEmail(WP_User $user, string $reset_key): void { $site_name = get_bloginfo('name'); $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login'); @@ -2450,7 +2450,7 @@ $sanitized[$key] = sanitize_key($value); } // Handle boolean fields elseif ($key === 'use_sandbox' || strpos($key, 'enable_') === 0) { elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) { $sanitized[$key] = (bool) $value; } // Handle numeric fields @@ -3346,8 +3346,7 @@ // Fetch from Square $response = $this->getRequest('orders/' . $order_id); if (!is_wp_error($response)) { $state = $response['order']['state'] ?? null; return $state; return $response['order']['state'] ?? null; } return null; @@ -3430,7 +3429,7 @@ } public function renderDashPage():void { $instance = $this->determineInstance(); $this->determineUser(); $crud = new CRUDSkeleton(); $crud->icon('receipt'); @@ -3452,13 +3451,7 @@ $crud->render(); } protected function determineInstance():self { if (current_user_can('manage_options')) { return self::$instances['base']; } return self::getInstance(get_current_user_id()); } public function getOrderPost(string $order_id):int|false { inc/integrations/services/Umami.php
File was renamed from inc/integrations/Umami.php @@ -1,10 +1,6 @@ <?php namespace JVBase\integrations; use JVBase\managers\Cache; use WP_Error; use WP_Post; use Exception; namespace JVBase\integrations\services; use JVBase\integrations\Integrations; if (!defined('ABSPATH')) { exit; @@ -88,7 +84,7 @@ 'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID' ]; parent::__construct($userID); parent::__construct(); $this->actions = array_merge( $this->actions, @@ -705,7 +701,7 @@ /** * Process queued operations */ public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array public function processOperation(array|\WP_Error $result, object $operation, array $data): array|\WP_Error { $prefix = strtolower($this->service_name) . '_'; inc/managers/Dashboard/DashboardManager.php
@@ -1,19 +1,7 @@ <?php namespace JVBase\managers\Dashboard; use JetBrains\PhpStorm\NoReturn; use JVBase\forms\TaxonomySelector; use JVBase\base\Site; use JVBase\managers\Cache; use JVBase\managers\IconsManager; use JVBase\managers\LoginManager; use JVBase\managers\RoleManager; use JVBase\meta\Form; use JVBase\meta\Meta; use JVBase\registrar\Fields; use JVBase\registrar\Registrar; use JVBase\ui\Navigation; use JVBase\ui\Tabs; use JetBrains\PhpStorm\NoReturn;use JVBase\base\Site;use JVBase\forms\TaxonomySelector;use JVBase\managers\Cache;use JVBase\managers\IconsManager;use JVBase\managers\LoginManager;use JVBase\managers\RoleManager;use JVBase\meta\Form;use JVBase\meta\Meta;use JVBase\registrar\Fields;use JVBase\registrar\Registrar;use JVBase\ui\Navigation;use JVBase\ui\Tabs; if (!defined('ABSPATH')) { exit; @@ -524,16 +512,16 @@ foreach (array_keys(Site::getIntegrations()) as $integration) { $integration = match($integration) { 'maps' => 'JVBase\integrations\GoogleMaps', 'square' => 'JVBase\integrations\Square', 'facebook' => 'JVBase\integrations\Facebook', 'helcim' => 'JVBase\integrations\Helcim', 'instagram' => 'JVBase\integrations\Instagram', 'bluesky' => 'JVBase\integrations\BlueSky', 'gmb' => 'JVBase\integrations\GoogleMyBusiness', 'cloudflare' => 'JVBase\integrations\Cloudflare', 'umami' => 'JVBase\integrations\Umami', 'postmark' => 'JVBase\integrations\PostMark', 'maps' => 'JVBase\integrations\services\GoogleMaps', 'square' => 'JVBase\integrations\services\Square', 'facebook' => 'JVBase\integrations\services\Facebook', 'helcim' => 'JVBase\integrations\services\Helcim', 'instagram' => 'JVBase\integrations\services\Instagram', 'bluesky' => 'JVBase\integrations\services\BlueSky', 'gmb' => 'JVBase\integrations\services\GoogleMyBusiness', 'cloudflare' => 'JVBase\integrations\services\Cloudflare', 'umami' => 'JVBase\integrations\services\Umami', 'postmark' => 'JVBase\integrations\services\PostMark', }; $title = $integration::title(); $icon = $integration::icon(); inc/rest/routes/FeedRoutes.php
@@ -1,17 +1,17 @@ <?php namespace JVBase\rest\routes; use JVBase\base\Site; use JVBase\meta\Meta; use JVBase\registrar\Registrar; use JVBase\rest\Rest; use JVBase\integrations\Umami; use JVBase\rest\Route; use JVBase\base\Site; use WP_Query; use Umami; use WP_Post; use WP_Term; use WP_Query; use WP_REST_Request; use WP_REST_Response; use WP_Term; if (!defined('ABSPATH')) { exit; // Exit if accessed directly inc/rest/routes/IntegrationsSquareRoutes.php
@@ -2,10 +2,10 @@ namespace JVBase\rest\routes; use JVBase\integrations\Square; use Exception; use JVBase\integrations\services\Square; use JVBase\meta\Meta; use JVBase\rest\Rest; use Exception; use JVBase\rest\Route; use WP_REST_Request; use WP_REST_Response; inc/ui/Checkout.php
@@ -31,11 +31,13 @@ { public static function getProvider():Integrations|false { foreach (Site::getIntegrations() as $integration => $active) { if (in_array($integration, ['square', 'helcim']) && $active) { return JVB()->connect($integration); } } $int = ['square','helcim']; foreach ($int as $i) { if (Site::hasIntegration($i)) { error_log('Site has '.$i); return JVB()->connect($i); } } return false; } /** src/menu/style.scss
@@ -4,7 +4,7 @@ font-size: var(--txt-medium); } .field.quantity > label { .field.quantity.quantity > label { position: absolute; left: var(--offScreen); } @@ -27,7 +27,7 @@ } > section * { max-width: var(--wide); max-width: var(--wide)!important; } }