0 && substr($haystack, -strlen($needle)) === $needle); } } final class Plugin { private const OPTION = 'yektabot_wc_webhook'; private const PAGE = 'yektabot-wc-webhook'; private const VERSION = '3.1.2'; private const TOPICS = ['product.created', 'product.updated', 'product.deleted']; private const LOG_OPTION = 'yektabot_wc_webhook_log'; private const CONNECT_OPTION = 'yektabot_wc_webhook_connect'; private const FULL_SYNC_OPTION = 'yektabot_wc_webhook_full_sync'; private const TRACK_OPTION = 'yektabot_wc_webhook_track'; private const INSTALL_ID_OPTION = 'yektabot_wc_install_id'; private const PAYMENT_SYNC_OPTION = 'yektabot_wc_webhook_payment_sync'; private const CONNECTION_OK_TRANSIENT = 'yektabot_wc_connection_ok'; private const CONNECT_URL = 'https://app.yektabot.com/wordpress/woocommerce/connect'; private const CREDENTIALS_URL = 'https://app.yektabot.com/woocommerce/plugin-credentials'; private const SERVICE_SETTINGS_URL = 'https://app.yektabot.com/woocommerce/plugin-events'; private const REQUIRE_URL = 'https://app.yektabot.com/woocommerce/plugin-requirements'; private const PAYMENT_CONFIRM_URL = 'https://app.yektabot.com/woocommerce/plugin-payment'; private const PRODUCT_CONNECT_URL = 'https://app.yektabot.com/woocommerce/plugin-product/connect'; private const PRODUCT_DISCONNECT_URL = 'https://app.yektabot.com/woocommerce/plugin-product/disconnect'; private const PRODUCT_STATUS_URL = 'https://app.yektabot.com/woocommerce/plugin-product/status'; private const PRODUCT_UNIQUE_CODE_URL = 'https://app.yektabot.com/woocommerce/plugin-product/unique-code'; private const META_CONNECTED = '_yektabot_connected'; private const META_FLOW_ID = '_yektabot_flow_id'; private const META_UNIQUE_CODE = '_yektabot_unique_code'; private const META_PENDING_CONNECT = '_yektabot_pending_connect'; private const META_CONNECT_PAYLOAD = '_yektabot_connect_payload'; private const META_STRUCTURE_HASH = '_yektabot_structure_hash'; private static ?string $webhookTable = null; private static ?string $bootError = null; private static bool $renderingPayment = false; /** When true, skip outbound webhooks (YB→WC push applied locally). */ private static bool $suppressOutbound = false; /** Maps a variation post ID (about to be deleted) to its parent product ID, for post-delete structure checks. */ private static array $pendingStructureCheck = []; private const MIN_PHP = '8.1'; public static function boot(): void { $error = self::preflightError(); if ($error !== null) { self::registerSafeMode($error); return; } add_action('admin_menu', [self::class, 'menu']); add_action('admin_bar_menu', [self::class, 'adminBar'], 90); add_action('admin_init', [self::class, 'settings']); add_action('admin_init', [self::class, 'handleAdminActions'], 5); add_action('admin_init', [self::class, 'maybeSyncPaymentSettings'], 15); add_action('admin_enqueue_scripts', [self::class, 'enqueueAdminAssets']); add_action('wp_enqueue_scripts', [self::class, 'enqueuePaymentAssets'], 99); add_action('template_redirect', [self::class, 'maybePaymentBridge'], 0); add_action('post_submitbox_start', [self::class, 'renderProductSubmitboxToggle']); add_action('admin_enqueue_scripts', [self::class, 'enqueueProductAdminAssets']); add_action('admin_footer', [self::class, 'renderSharedProductModals']); add_filter('manage_edit-product_columns', [self::class, 'addProductListColumn']); add_action('manage_product_posts_custom_column', [self::class, 'renderProductListColumn'], 10, 2); add_action('wp_ajax_yektabot_product_connect', [self::class, 'ajaxProductConnect']); add_action('wp_ajax_yektabot_product_disconnect', [self::class, 'ajaxProductDisconnect']); add_action('wp_ajax_yektabot_product_status', [self::class, 'ajaxProductStatus']); add_action('wp_ajax_yektabot_product_defaults', [self::class, 'ajaxProductDefaults']); add_action('transition_post_status', [self::class, 'onProductPublish'], 10, 3); add_action('deleted_post', [self::class, 'onPostDeleted'], 20, 1); add_action('rest_api_init', [self::class, 'registerRestRoutes']); // Capture WooCommerce webhook delivery results into our internal "فعالیت‌ها" log. add_action('woocommerce_webhook_process_delivery', [self::class, 'onWebhookProcessDelivery'], 10, 2); add_action('woocommerce_webhook_delivery', [self::class, 'onWebhookDelivery'], 10, 5); add_filter('woocommerce_webhook_should_deliver', [self::class, 'shouldDeliverWebhook'], 10, 3); // Also capture product events; if Action Scheduler / WP-Cron is broken, fall back to direct delivery. add_action('save_post_product', [self::class, 'onProductSaved'], 20, 3); add_action('save_post_product_variation', [self::class, 'onVariationSaved'], 20, 3); add_action('before_delete_post', [self::class, 'onBeforeDeletePost'], 20, 1); add_action('wp_trash_post', [self::class, 'onTrashProduct'], 20, 1); add_action('woocommerce_product_set_stock', [self::class, 'onStockChanged'], 20, 1); add_action('woocommerce_variation_set_stock', [self::class, 'onStockChanged'], 20, 1); add_action('woocommerce_product_set_stock_status', [self::class, 'onStockStatusChanged'], 20, 3); add_action('woocommerce_variation_set_stock_status', [self::class, 'onStockStatusChanged'], 20, 3); add_action('woocommerce_update_product', [self::class, 'onWcProductUpdated'], 20, 1); add_action('woocommerce_update_product_variation', [self::class, 'onWcProductUpdated'], 20, 1); add_action('update_option_' . self::OPTION, [self::class, 'sync'], 10, 0); add_action('admin_init', [self::class, 'maybeSync'], 99); register_activation_hook(__FILE__, [self::class, 'activate']); register_deactivation_hook(__FILE__, [self::class, 'deactivate']); } /** @return string[] */ private static function hookCallbacks(): array { return [ 'menu', 'adminBar', 'settings', 'handleAdminActions', 'enqueueAdminAssets', 'enqueuePaymentAssets', 'maybeSyncPaymentSettings', 'maybePaymentBridge', 'renderProductSubmitboxToggle', 'enqueueProductAdminAssets', 'renderSharedProductModals', 'addProductListColumn', 'renderProductListColumn', 'ajaxProductConnect', 'ajaxProductDisconnect', 'ajaxProductStatus', 'ajaxProductDefaults', 'onProductPublish', 'onPostDeleted', 'registerRestRoutes', 'restProductPush', 'authorizeProductPush', 'onWebhookProcessDelivery', 'onWebhookDelivery', 'shouldDeliverWebhook', 'onProductSaved', 'onVariationSaved', 'onBeforeDeletePost', 'onTrashProduct', 'onStockChanged', 'onStockStatusChanged', 'onWcProductUpdated', 'sync', 'maybeSync', 'activate', 'deactivate', ]; } public static function preflightError(): ?string { if (version_compare(PHP_VERSION, self::MIN_PHP, '<')) { return sprintf('PHP %s یا جدیدتر لازم است (فعلی: %s).', self::MIN_PHP, PHP_VERSION); } foreach (self::hookCallbacks() as $method) { if (!method_exists(self::class, $method)) { return sprintf('متد callback «%s» در پلاگین یافت نشد.', $method); } try { $ref = new \ReflectionMethod(self::class, $method); } catch (\ReflectionException $e) { return sprintf('متد callback «%s» قابل بررسی نیست.', $method); } if (!$ref->isPublic()) { return sprintf('متد «%s» باید public باشد تا وردپرس بتواند آن را فراخوانی کند.', $method); } } return null; } private static function registerSafeMode(string $error): void { self::$bootError = $error; add_action('admin_notices', [self::class, 'renderBootErrorNotice']); register_activation_hook(__FILE__, [self::class, 'activate']); } public static function renderBootErrorNotice(): void { if (self::$bootError === null || !current_user_can('activate_plugins')) { return; } echo '

Yektabot: ' . esc_html(self::$bootError) . ' پلاگین در حالت ایمن است؛ هوک‌های مدیریت ثبت نشده‌اند. نسخهٔ سالم را نصب کنید یا پلاگین را غیرفعال کنید.

'; } public static function onWebhookProcessDelivery($webhook, $arg): void { try { if (!is_object($webhook) || !method_exists($webhook, 'get_name')) return; $name = (string)$webhook->get_name(); if (!str_starts_with($name, 'Yektabot ')) return; $topic = method_exists($webhook, 'get_topic') ? (string)$webhook->get_topic() : ''; self::log('info', 'webhook_dispatch', ['topic' => $topic, 'name' => $name, 'arg' => $arg]); } catch (\Throwable $e) { } } public static function shouldDeliverWebhook($shouldDeliver, $webhook, $arg): bool { try { if (self::$suppressOutbound) return false; if (!$shouldDeliver) return false; if (!is_object($webhook) || !method_exists($webhook, 'get_name')) return (bool)$shouldDeliver; $name = (string)$webhook->get_name(); if (!str_starts_with($name, 'Yektabot ')) return (bool)$shouldDeliver; $topic = method_exists($webhook, 'get_topic') ? (string)$webhook->get_topic() : ''; if (!str_starts_with($topic, 'product.')) return (bool)$shouldDeliver; $productId = self::resolveConnectedParentId((int)$arg); if ($productId <= 0) return false; if ($topic === 'product.deleted') { return self::isProductConnected($productId) || self::productHasFlowMeta($productId); } return self::isProductConnected($productId); } catch (\Throwable $e) { return (bool)$shouldDeliver; } } public static function onWebhookDelivery($httpArgs, $response, $duration, $arg, $webhookId): void { try { $webhookId = (int)$webhookId; $name = ''; $topic = ''; $deliveryUrl = ''; if (class_exists('\WC_Webhook') && $webhookId > 0) { $wh = new \WC_Webhook($webhookId); $name = (string)$wh->get_name(); if (!str_starts_with($name, 'Yektabot ')) return; $topic = (string)$wh->get_topic(); $deliveryUrl = (string)$wh->get_delivery_url(); } else { return; } $code = null; $error = null; if (is_wp_error($response)) { $error = $response->get_error_message(); } elseif (is_array($response)) { $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($response) : null; } $level = ($code && $code >= 200 && $code < 300) ? 'info' : 'warn'; if ($error) $level = 'error'; self::log($level, 'webhook_delivery', [ 'id' => $webhookId, 'topic' => $topic, 'name' => $name, 'url' => $deliveryUrl, 'status' => $code, 'error' => $error, 'duration_ms' => is_numeric($duration) ? (int)round(((float)$duration) * 1000) : null, ]); } catch (\Throwable $e) { } } public static function onProductSaved($postId, $post, $update): void { try { if (self::$suppressOutbound) return; $postId = (int)$postId; if ($postId <= 0) return; if (function_exists('wp_is_post_revision') && wp_is_post_revision($postId)) return; if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (!is_object($post) || (($post->post_type ?? '') !== 'product')) return; $parentId = self::resolveConnectedParentId($postId); if ($parentId <= 0 || !self::isProductConnected($parentId)) return; $postStatus = (string)($post->post_status ?? ''); $topic = $postStatus === 'trash' ? 'product.deleted' : ($update ? 'product.updated' : 'product.created'); self::log('info', 'product_event', ['topic' => $topic, 'id' => $parentId, 'post_status' => $postStatus]); if ($topic === 'product.deleted') { self::unlinkProductLocally($parentId, true); } // Always push price/stock updates for connected products (don't rely only on WP-Cron). self::directDeliver($topic, $parentId); } catch (\Throwable $e) { } } public static function onVariationSaved($postId, $post, $update): void { try { if (self::$suppressOutbound) return; $postId = (int)$postId; if ($postId <= 0) return; if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; $variation = function_exists('wc_get_product') ? wc_get_product($postId) : null; $parentId = $variation && method_exists($variation, 'get_parent_id') ? (int)$variation->get_parent_id() : (int)($post->post_parent ?? 0); if ($parentId > 0) self::maybeHandleStructureChange($parentId); $connectedParentId = self::resolveConnectedParentId($postId); if ($connectedParentId <= 0 || !self::isProductConnected($connectedParentId)) return; self::directDeliver('product.updated', $connectedParentId); } catch (\Throwable $e) { } } public static function onStockChanged($product): void { try { if (self::$suppressOutbound) return; $id = 0; if (is_object($product) && method_exists($product, 'get_id')) { $id = (int)$product->get_id(); } elseif (is_numeric($product)) { $id = (int)$product; } $parentId = self::resolveConnectedParentId($id); if ($parentId <= 0 || !self::isProductConnected($parentId)) return; self::directDeliver('product.updated', $parentId); } catch (\Throwable $e) { } } public static function onStockStatusChanged($productId, $status = null, $product = null): void { try { if (self::$suppressOutbound) return; $parentId = self::resolveConnectedParentId((int)$productId); if ($parentId <= 0 || !self::isProductConnected($parentId)) return; self::directDeliver('product.updated', $parentId); } catch (\Throwable $e) { } } public static function onWcProductUpdated($product): void { try { if (self::$suppressOutbound) return; $prod = is_object($product) && method_exists($product, 'get_id') ? $product : (function_exists('wc_get_product') ? wc_get_product((int)$product) : null); $id = $prod && method_exists($prod, 'get_id') ? (int)$prod->get_id() : (int)$product; $structureParentId = ($prod && method_exists($prod, 'get_parent_id') && (int)$prod->get_parent_id() > 0) ? (int)$prod->get_parent_id() : $id; self::maybeHandleStructureChange($structureParentId); $parentId = self::resolveConnectedParentId($id); if ($parentId <= 0 || !self::isProductConnected($parentId)) return; self::directDeliver('product.updated', $parentId); } catch (\Throwable $e) { } } public static function onBeforeDeletePost($postId): void { try { $postId = (int)$postId; if ($postId <= 0) return; $type = function_exists('get_post_type') ? (string)get_post_type($postId) : ''; if (!in_array($type, ['product', 'product_variation'], true)) return; if ($type === 'product_variation') { // Remember the parent so we can re-check the structure fingerprint *after* deletion completes. $variation = function_exists('wc_get_product') ? wc_get_product($postId) : null; $variationParentId = $variation && method_exists($variation, 'get_parent_id') ? (int)$variation->get_parent_id() : 0; if ($variationParentId > 0) self::$pendingStructureCheck[$postId] = $variationParentId; } $parentId = self::resolveConnectedParentId($postId); if ($parentId <= 0) return; if (!self::isProductConnected($parentId) && !self::productHasFlowMeta($parentId)) return; // Only unlink when the parent product itself is deleted (not a single variation). if ($type === 'product') { self::remoteDisconnectProduct($parentId); self::unlinkProductLocally($parentId, true); self::clearPendingConnect($parentId); delete_post_meta($parentId, self::META_STRUCTURE_HASH); self::log('info', 'product_event', ['topic' => 'product.deleted', 'id' => $parentId, 'unlink_only' => true]); self::directDeliver('product.deleted', $parentId); } else { self::directDeliver('product.updated', $parentId); } } catch (\Throwable $e) { } } /** * Fires after a post is fully deleted. Used to re-check the structure fingerprint of a * variable product's parent once one of its variations has actually been removed. */ public static function onPostDeleted($postId): void { try { $postId = (int)$postId; if ($postId <= 0 || !isset(self::$pendingStructureCheck[$postId])) return; $parentId = self::$pendingStructureCheck[$postId]; unset(self::$pendingStructureCheck[$postId]); if ($parentId > 0) self::maybeHandleStructureChange($parentId); } catch (\Throwable $e) { } } public static function onTrashProduct($postId): void { try { $postId = (int)$postId; if ($postId <= 0) return; if (function_exists('get_post_type') && get_post_type($postId) !== 'product') return; if (!self::isProductConnected($postId)) return; self::remoteDisconnectProduct($postId); self::unlinkProductLocally($postId, true); self::clearPendingConnect($postId); delete_post_meta($postId, self::META_STRUCTURE_HASH); } catch (\Throwable $e) { } } private static function shouldDirectDeliver(): bool { // If WP-Cron is disabled, Action Scheduler won't run automatically. if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) return true; // If Action Scheduler queue runner isn't scheduled or is overdue, assume deliveries won't happen. if (function_exists('wp_next_scheduled')) { $next = wp_next_scheduled('action_scheduler_run_queue'); if (!$next) return true; if (is_numeric($next) && (time() - (int)$next) > 600) return true; // overdue by 10 min } return false; } private static function directDeliver(string $topic, int $productId): void { try { if (self::$suppressOutbound) return; if ($topic !== 'product.deleted' && !self::isProductConnected($productId)) { self::log('info', 'direct_delivery_skipped', ['reason' => 'not_connected_product', 'topic' => $topic, 'id' => $productId]); return; } $opts = self::opts(); if (!self::hasConnection($opts)) { self::log('warn', 'direct_delivery_skipped', ['reason' => 'not_connected', 'topic' => $topic, 'id' => $productId]); return; } $url = self::target($opts); $payload = self::buildProductSyncPayload($productId); $body = wp_json_encode($payload); if (!$body) $body = '{"id":' . (int)$productId . '}'; $sig = base64_encode(hash_hmac('sha256', $body, (string)$opts['secret'], true)); $headers = [ 'Content-Type' => 'application/json', 'X-WC-Webhook-Topic' => $topic, 'X-WC-Webhook-Signature' => $sig, 'X-WC-Webhook-Source' => home_url('/'), ]; $resp = wp_remote_post($url, ['timeout' => 15, 'redirection' => 0, 'headers' => $headers, 'body' => $body]); if (is_wp_error($resp)) { self::log('error', 'direct_delivery', ['topic' => $topic, 'id' => $productId, 'error' => $resp->get_error_message()]); return; } $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($resp) : null; self::log(($code >= 200 && $code < 300) ? 'info' : 'warn', 'direct_delivery', ['topic' => $topic, 'id' => $productId, 'status' => $code, 'url' => $url]); } catch (\Throwable $e) { self::log('error', 'direct_delivery', ['topic' => $topic, 'id' => $productId, 'error' => $e->getMessage()]); } } public static function menu(): void { $icon = 'dashicons-admin-links'; add_menu_page('یکتابات', 'یکتابات', 'manage_options', self::PAGE, [self::class, 'page'], $icon, 56); } public static function adminBar($wp_admin_bar): void { if (!is_admin() || !current_user_can('manage_options')) return; if (!is_object($wp_admin_bar) || !method_exists($wp_admin_bar, 'add_node')) return; $title = 'یکتابات'; $wp_admin_bar->add_node([ 'id' => 'yektabot', 'title' => $title, 'href' => admin_url('admin.php?page=' . self::PAGE), 'meta' => ['class' => 'yektabot-adminbar'], ]); } /** Hard-coded list-table CSS — printed in admin_head so it always applies on products list. */ public static function settings(): void { register_setting(self::PAGE, self::OPTION, ['type' => 'array', 'sanitize_callback' => [self::class, 'sanitize']]); } public static function page(): void { if (!current_user_can('manage_options')) return; $opts = self::opts(); $checks = self::readyDetails(); echo '
'; echo '
'; $conn = self::connectionStatus(); if (isset($_GET['disconnected']) && is_string($_GET['disconnected']) && sanitize_text_field(wp_unslash($_GET['disconnected'])) === '1') { echo '
اتصال قطع شد.
'; } if ($conn === false) { echo '
اتصال معتبر نیست یا از سمت یکتابات قطع شده است. لطفاً اتصال را مجدد انجام دهید یا قطع اتصال بزنید.
'; } if (isset($_GET['connected']) && is_string($_GET['connected'])) { $ok = sanitize_text_field(wp_unslash($_GET['connected'])) === '1'; $msg = $ok ? 'اتصال انجام شد و تنظیمات خودکار ذخیره شد.' : 'اتصال ناموفق بود.'; $bg = $ok ? 'rgba(65,241,135,.22)' : 'rgba(214,216,224,.55)'; $color = $ok ? 'var(--yb-primary-600)' : 'var(--yb-neutral-11)'; echo '
' . esc_html($msg) . '
'; } echo '
YektaBot

اتصال فروشگاه ووکامرس به یکتابات

پس از اتصال فروشگاه، از صفحه هر محصول می‌توانید آن را به یکتابات وصل کنید. فقط قیمت و موجودی به‌صورت دوطرفه همگام می‌شوند.
'; echo '

با انتخاب «اتصال خودکار»، اجازه می‌دهید آدرس سایت و شناسه اتصال به سرویس یکتابات در app.yektabot.com ارسال شود. افزونه کلید API ووکامرس با دسترسی خواندن و نوشتن می‌سازد و پس از تکمیل اتصال در اختیار یکتابات قرار می‌دهد. اطلاعات محصولاتی که انتخاب می‌کنید و تغییرات قیمت و موجودی آن‌ها، وضعیت اتصال و تنظیمات و نتیجهٔ پرداخت با یکتابات تبادل می‌شود. اتصال محصول منتشرنشده تا زمان انتشار آن در فروشگاه به تعویق می‌افتد. پیش از اتصال هیچ درخواستی به سرویس ارسال نمی‌شود. با «قطع اتصال»، وبهوک‌ها و کلید API این افزونه حذف می‌شوند. استفاده از این قابلیت‌ها به حساب یکتابات نیاز دارد. شرایط استفاده و حریم خصوصی

'; echo '
'; $maskedSecret = $opts['secret'] ? self::mask((string)$opts['secret'], 4) : '—'; $maskedAccount = $opts['account_id'] ? self::mask((string)$opts['account_id'], 2) : '—'; $maskedUrl = $opts['url'] ? self::maskUrl((string)$opts['url'], (string)$opts['account_id']) : '—'; echo '
اتصال
تنظیمات به‌صورت خودکار از یکتابات دریافت می‌شود
'; $connected = !empty($opts['url']) && !empty($opts['secret']) && !empty($opts['account_id']); $pill = $connected ? ('' . ($conn === false ? 'نامعتبر' : 'فعال') . '') : 'قطع'; echo '
' . ($connected ? 'در صورت نیاز می‌توانید اتصال را مجدد انجام دهید' : 'برای دریافت تنظیمات، اتصال خودکار را انجام دهید') . '
' . wp_kses_post($pill) . '
'; echo '
'; wp_nonce_field('yb_connect'); echo '
'; if ($connected) { echo '
'; wp_nonce_field('yb_disconnect'); echo '
'; } echo '
'; echo '
'; $platform = self::platformLabel((string)($opts['platform'] ?? '')); $accountUsername = (string)($opts['account_username'] ?? ($opts['ig_username'] ?? '')); $accountName = (string)($opts['account_name'] ?? ($opts['ig_name'] ?? '')); $account = ($accountUsername || $accountName) ? trim(($accountName ? $accountName . ' ' : '') . ($accountUsername ? '@' . ltrim($accountUsername, '@') : '')) : ''; if ($account) echo '
' . esc_html($platform) . '
' . esc_html($account) . '
فعال
'; echo '
آدرس وبهوک
' . esc_html($maskedUrl) . '
'; echo '
کلید امنیتی
' . esc_html($maskedSecret) . '
'; echo '
شناسه اکانت
' . esc_html($maskedAccount) . '
'; echo '
'; echo '
اتصال محصولات از صفحه «محصولات»، ستون «یکتابات» یا صفحه ویرایش هر محصول انجام می‌شود. فقط قیمت و موجودی پس از اتصال همگام می‌شوند.
'; echo '
'; echo '
وضعیت
وضعیت اتصال و موتور وبهوک
'; echo '
'; foreach ($checks as $check) { $pill = '' . ($check['ok'] ? 'فعال' : 'نیاز به بررسی') . ''; echo '
' . esc_html($check['label']) . '
' . esc_html($check['detail']) . '
' . wp_kses_post($pill) . '
'; } echo '
'; echo '
'; if ($connected) { self::renderProductConnectCard(); } echo '
'; echo '
'; } private static function startConnect(): void { $state = function_exists('random_bytes') ? bin2hex(random_bytes(16)) : (function_exists('wp_generate_password') ? wp_generate_password(32, false) : md5(uniqid('', true))); self::ensureApiCredentials(); update_option(self::CONNECT_OPTION, ['state' => $state, 'at' => time(), 'site' => home_url('/'), 'user_id' => get_current_user_id()], false); $returnUrl = add_query_arg('_wpnonce', wp_create_nonce('yb_connect_callback'), admin_url('admin.php?page=' . self::PAGE . '&yb_action=connected')); $site = home_url('/'); $url = self::CONNECT_URL . '?' . http_build_query(['return_url' => $returnUrl, 'state' => $state, 'site' => $site]); self::redirectToService($url); exit; } private static function handleConnectCallback(): void { $nonce = isset($_GET['_wpnonce']) && is_string($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : ''; if (!current_user_can('manage_options') || !wp_verify_nonce($nonce, 'yb_connect_callback')) { wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&connected=0')); exit; } $stored = get_option(self::CONNECT_OPTION, []); $storedState = is_array($stored) ? ($stored['state'] ?? null) : null; $state = isset($_GET['state']) && is_string($_GET['state']) ? sanitize_text_field(wp_unslash($_GET['state'])) : null; if (!$storedState || !$state || !hash_equals((string)$storedState, (string)$state) || (int)($stored['user_id'] ?? 0) !== get_current_user_id() || (int)($stored['at'] ?? 0) < time() - 15 * MINUTE_IN_SECONDS) { wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&connected=0')); exit; } $payload = [ 'url' => isset($_GET['url']) && is_string($_GET['url']) ? esc_url_raw(wp_unslash($_GET['url'])) : (isset($_GET['url']) ? '' : 'https://app.yektabot.com/woocommerce/webhook'), 'secret' => isset($_GET['secret']) ? sanitize_text_field(wp_unslash($_GET['secret'])) : '', 'account_id' => isset($_GET['account_id']) ? sanitize_text_field(wp_unslash($_GET['account_id'])) : '', 'platform' => isset($_GET['platform']) ? sanitize_text_field(wp_unslash($_GET['platform'])) : '', 'account_username' => isset($_GET['account_username']) ? sanitize_text_field(wp_unslash($_GET['account_username'])) : '', 'account_name' => isset($_GET['account_name']) ? sanitize_text_field(wp_unslash($_GET['account_name'])) : '', 'ig_username' => isset($_GET['ig_username']) ? sanitize_text_field(wp_unslash($_GET['ig_username'])) : '', 'ig_name' => isset($_GET['ig_name']) ? sanitize_text_field(wp_unslash($_GET['ig_name'])) : '', ]; if (empty($payload['account_username']) && !empty($payload['ig_username'])) $payload['account_username'] = $payload['ig_username']; if (empty($payload['account_name']) && !empty($payload['ig_name'])) $payload['account_name'] = $payload['ig_name']; $payload['url'] = self::sanitizeWebhookUrl($payload['url']); if (!self::hasConnection($payload) || !ctype_digit((string)$payload['account_id']) || (int)$payload['account_id'] <= 0) { delete_option(self::CONNECT_OPTION); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&connected=0')); exit; } $old = self::opts(); $oldTarget = ($old['url'] && $old['secret']) ? self::target($old) : ''; update_option(self::OPTION, self::sanitize(array_merge($old, $payload)), false); delete_option(self::CONNECT_OPTION); $new = self::opts(); $newTarget = ($new['url'] && $new['secret']) ? self::target($new) : ''; if ($oldTarget && $newTarget && $oldTarget !== $newTarget) { self::cleanupByUrl($oldTarget); self::log('info', 'reconnected_detached_previous', ['old_url' => $oldTarget, 'new_url' => $newTarget]); } self::ensureApiCredentials(); self::pushCredentials(); delete_transient(self::CONNECTION_OK_TRANSIENT); self::sendServiceSettings('connected'); if ($payload['url'] && $payload['secret']) self::sync(true); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&connected=1')); exit; } private static function ensureApiCredentials(): void { try { self::loadWoo(); $opts = self::opts(); $woo = class_exists('\WooCommerce') || function_exists('WC'); if (!$woo || !function_exists('wp_generate_password')) { self::log('warn', 'api_keys_skipped', ['reason' => 'woocommerce_missing']); return; } global $wpdb; if (!isset($wpdb) || !property_exists($wpdb, 'prefix') || !method_exists($wpdb, 'insert') || !method_exists($wpdb, 'get_var') || !method_exists($wpdb, 'prepare')) return; $table = $wpdb->prefix . 'woocommerce_api_keys'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) { self::log('warn', 'api_keys_skipped', ['reason' => 'table_missing']); return; } $userId = function_exists('get_current_user_id') ? (int)get_current_user_id() : 0; if ($userId <= 0) return; $consumerKey = trim((string)($opts['consumer_key'] ?? '')); $consumerSecret = trim((string)($opts['consumer_secret'] ?? '')); $apiKeyId = (int)($opts['api_key_id'] ?? 0); if ($consumerKey && $consumerSecret) { $hashedKey = function_exists('wc_api_hash') ? wc_api_hash($consumerKey) : hash('sha256', $consumerKey); if ($apiKeyId > 0) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. $exists = (int)$wpdb->get_var($wpdb->prepare("SELECT key_id FROM {$table} WHERE key_id = %d LIMIT 1", $apiKeyId)); if ($exists > 0) return; } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. $foundId = (int)$wpdb->get_var($wpdb->prepare("SELECT key_id FROM {$table} WHERE consumer_key = %s LIMIT 1", $hashedKey)); if ($foundId > 0) { if ($apiKeyId !== $foundId) { $opts['api_key_id'] = (string)$foundId; update_option(self::OPTION, $opts, false); } return; } $truncated = substr($consumerKey, -7); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. $foundId = (int)$wpdb->get_var($wpdb->prepare("SELECT key_id FROM {$table} WHERE truncated_key = %s AND description = %s ORDER BY key_id DESC LIMIT 1", $truncated, 'Yektabot')); if ($foundId > 0 && method_exists($wpdb, 'update')) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. $wpdb->update($table, [ 'user_id' => $userId, 'description' => 'Yektabot', 'permissions' => 'read_write', 'consumer_key' => $hashedKey, 'consumer_secret' => $consumerSecret, 'truncated_key' => $truncated, ], ['key_id' => $foundId]); $opts['api_key_id'] = (string)$foundId; update_option(self::OPTION, $opts, false); self::log('info', 'api_keys_created', ['id' => (string)$foundId, 'restored' => true]); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. $ok = (bool)$wpdb->insert($table, [ 'user_id' => $userId, 'description' => 'Yektabot', 'permissions' => 'read_write', 'consumer_key' => $hashedKey, 'consumer_secret' => $consumerSecret, 'nonces' => null, 'truncated_key' => $truncated, 'last_access' => null, ]); if ($ok) { $opts['api_key_id'] = (string)($wpdb->insert_id ?? ''); update_option(self::OPTION, $opts, false); self::log('info', 'api_keys_created', ['id' => $opts['api_key_id'] ?: null, 'restored' => true]); return; } } // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. if (method_exists($wpdb, 'query')) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE description = %s", 'Yektabot')); $consumerKey = 'ck_' . wp_generate_password(32, false, false); $consumerSecret = 'cs_' . wp_generate_password(40, false, false); $hashedKey = function_exists('wc_api_hash') ? wc_api_hash($consumerKey) : hash('sha256', $consumerKey); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- WooCommerce API-key provisioning has no public CRUD API; the table is the trusted WordPress prefix plus a literal name, values are prepared, and a fresh read is required before creating/replacing keys. $ok = (bool)$wpdb->insert($table, [ 'user_id' => $userId, 'description' => 'Yektabot', 'permissions' => 'read_write', 'consumer_key' => $hashedKey, 'consumer_secret' => $consumerSecret, 'nonces' => null, 'truncated_key' => substr($consumerKey, -7), 'last_access' => null, ]); if (!$ok) { self::log('error', 'api_keys_failed', ['error' => (string)($wpdb->last_error ?? '')]); return; } $opts['consumer_key'] = $consumerKey; $opts['consumer_secret'] = $consumerSecret; $opts['api_key_id'] = (string)($wpdb->insert_id ?? ''); update_option(self::OPTION, $opts, false); self::log('info', 'api_keys_created', ['id' => $opts['api_key_id'] ?: null]); } catch (\Throwable $e) { self::log('error', 'api_keys_failed', ['error' => $e->getMessage()]); } } private static function pushCredentials(): void { try { $opts = self::opts(); if (!self::hasConnection($opts) || empty($opts['consumer_key']) || empty($opts['consumer_secret'])) return; if (!function_exists('wp_remote_post') || !function_exists('wp_remote_retrieve_response_code') || !function_exists('wp_remote_retrieve_body')) return; $body = wp_json_encode([ 'account_id' => (int)$opts['account_id'], 'site' => home_url('/'), 'consumer_key' => (string)$opts['consumer_key'], 'consumer_secret' => (string)$opts['consumer_secret'], ]); if (!$body) return; $sig = base64_encode(hash_hmac('sha256', $body, (string)$opts['secret'], true)); $res = wp_remote_post(self::CREDENTIALS_URL, [ 'timeout' => 15, 'redirection' => 0, 'headers' => [ 'Content-Type' => 'application/json', 'X-Yektabot-Signature' => $sig, ], 'body' => $body, ]); if (is_wp_error($res)) { self::log('error', 'api_keys_push_failed', ['error' => $res->get_error_message()]); return; } $code = (int)wp_remote_retrieve_response_code($res); self::log(($code >= 200 && $code < 300) ? 'info' : 'warn', 'api_keys_pushed', ['status' => $code, 'body_len' => strlen((string)wp_remote_retrieve_body($res))]); } catch (\Throwable $e) { self::log('error', 'api_keys_push_failed', ['error' => $e->getMessage()]); } } private static function opts(): array { $defaults = ['url' => '', 'secret' => '', 'account_id' => '', 'platform' => '', 'account_username' => '', 'account_name' => '', 'ig_username' => '', 'ig_name' => '', 'consumer_key' => '', 'consumer_secret' => '', 'api_key_id' => '', 'logs_enabled' => '1', 'payment_enabled' => '1', 'payment_gateway_default' => '', 'zarinpal_merchant_id' => '', 'zibal_merchant_id' => '', 'payment_merchant_source' => 'auto']; $value = get_option(self::OPTION, []); return array_merge($defaults, is_array($value) ? $value : []); } private static function target(array $opts): string { $base = self::sanitizeWebhookUrl($opts['url'] ?? ''); if (!$opts['account_id']) return $base; $parts = wp_parse_url($base); if (!$parts) return $base; $query = []; if (!empty($parts['query'])) parse_str($parts['query'], $query); unset($query['account_id']); $query['account_id'] = (string)$opts['account_id']; $scheme = isset($parts['scheme']) ? ($parts['scheme'] . '://') : ''; $user = $parts['user'] ?? ''; $pass = isset($parts['pass']) ? ':' . $parts['pass'] : ''; $auth = $user ? $user . $pass . '@' : ''; $host = $parts['host'] ?? ''; $port = isset($parts['port']) ? ':' . $parts['port'] : ''; $path = $parts['path'] ?? ''; $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : ''; $rebuilt = $scheme . $auth . $host . $port . $path; $rebuilt .= $query ? ('?' . http_build_query($query)) : ''; return $rebuilt . $fragment; } public static function sanitize($value): array { $value = is_array($value) ? $value : []; $gateway = sanitize_text_field($value['payment_gateway_default'] ?? ''); if (!in_array($gateway, ['zarinpal', 'zibal'], true)) $gateway = ''; $platform = strtolower(sanitize_text_field($value['platform'] ?? '')); if (!in_array($platform, ['instagram', 'telegram', 'bale'], true)) $platform = ''; $source = sanitize_text_field($value['payment_merchant_source'] ?? 'auto'); if (!in_array($source, ['auto'], true)) $source = 'auto'; return [ 'url' => self::sanitizeWebhookUrl($value['url'] ?? ''), 'secret' => sanitize_text_field($value['secret'] ?? ''), 'account_id' => sanitize_text_field($value['account_id'] ?? ''), 'platform' => $platform, 'account_username' => sanitize_text_field($value['account_username'] ?? ''), 'account_name' => sanitize_text_field($value['account_name'] ?? ''), 'ig_username' => sanitize_text_field($value['ig_username'] ?? ''), 'ig_name' => sanitize_text_field($value['ig_name'] ?? ''), 'consumer_key' => sanitize_text_field($value['consumer_key'] ?? ''), 'consumer_secret' => sanitize_text_field($value['consumer_secret'] ?? ''), 'api_key_id' => sanitize_text_field($value['api_key_id'] ?? ''), 'logs_enabled' => !empty($value['logs_enabled']) ? '1' : '0', 'payment_enabled' => !empty($value['payment_enabled']) ? '1' : '0', 'payment_gateway_default' => $gateway, 'zarinpal_merchant_id' => sanitize_text_field($value['zarinpal_merchant_id'] ?? ''), 'zibal_merchant_id' => sanitize_text_field($value['zibal_merchant_id'] ?? ''), 'payment_merchant_source' => $source, ]; } private static function toggleLogs(): void { $opts = self::opts(); $opts['logs_enabled'] = empty($opts['logs_enabled']) ? '1' : '0'; update_option(self::OPTION, $opts, false); if (empty($opts['logs_enabled'])) self::clearLogs(); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '#yb-logs')); exit; } /** @return array{token:string,last_id:int,processed:int,updated:int,deleted:int,total:int,started_at:int,resumed?:bool} */ private static function beginFullSyncState(): array { $existing = get_option(self::FULL_SYNC_OPTION, []); if (is_array($existing)) { $token = (string)($existing['token'] ?? ''); $startedAt = (int)($existing['started_at'] ?? 0); // Reuse in-flight sync so a failed AJAX parse + form fallback cannot double-start. if ($token !== '' && $startedAt > 0 && (time() - $startedAt) < 7200) { if ((int)($existing['total'] ?? 0) <= 0) { $existing['total'] = self::fullSyncTotal(); update_option(self::FULL_SYNC_OPTION, $existing, false); } $existing['resumed'] = true; return $existing; } } $token = function_exists('random_bytes') ? bin2hex(random_bytes(16)) : (function_exists('wp_generate_password') ? wp_generate_password(32, false) : md5(uniqid('', true))); $state = [ 'token' => $token, 'last_id' => 0, 'processed' => 0, 'updated' => 0, 'deleted' => 0, 'total' => self::fullSyncTotal(), 'started_at' => time(), ]; update_option(self::FULL_SYNC_OPTION, $state, false); self::log('info', 'full_sync_started', []); self::notifySyncProgress('started', $state); return $state; } private static function startFullSync(): void { $opts = self::opts(); if (!self::hasConnection($opts)) { wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&full_sync=0')); exit; } self::beginFullSyncState(); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&yb_full_sync_started=1')); exit; } public static function ajaxFullSyncStart(): void { try { if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'forbidden']); } if (!check_ajax_referer('yb_full_sync_ajax', '_wpnonce', false)) { wp_send_json_error(['message' => 'bad_nonce']); } $opts = self::opts(); if (!self::hasConnection($opts)) { wp_send_json_error(['message' => 'not_ready']); } wp_send_json_success(self::beginFullSyncState()); } catch (\Throwable $e) { self::log('error', 'full_sync_start_failed', ['error' => $e->getMessage()]); wp_send_json_error(['message' => 'exception', 'detail' => $e->getMessage()]); } } public static function ajaxFullSyncStep(): void { try { if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'forbidden']); } if (!check_ajax_referer('yb_full_sync_ajax', '_wpnonce', false)) { wp_send_json_error(['message' => 'bad_nonce']); } $token = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : ''; $state = get_option(self::FULL_SYNC_OPTION, []); $stored = is_array($state) ? (string)($state['token'] ?? '') : ''; if (!$stored || !$token || !hash_equals($stored, $token)) { wp_send_json_error(['message' => 'invalid_state']); } $total = (int)($state['total'] ?? 0); if ($total <= 0) { $total = self::fullSyncTotal(); $state['total'] = $total; } $afterId = (int)($state['last_id'] ?? 0); $batch = self::fullSyncBatch($afterId, 20); if (!$batch) { delete_option(self::FULL_SYNC_OPTION); $processed = (int)($state['processed'] ?? 0); $updated = (int)($state['updated'] ?? 0); $deleted = (int)($state['deleted'] ?? 0); self::log('info', 'full_sync_completed', ['processed' => $processed, 'updated' => $updated, 'deleted' => $deleted]); self::notifySyncProgress('completed', [ 'token' => $token, 'total' => $total, 'processed' => $processed, 'updated' => $updated, 'deleted' => $deleted, 'done' => true, ]); wp_send_json_success(['done' => true, 'token' => $token, 'processed' => $processed, 'updated' => $updated, 'deleted' => $deleted, 'total' => $total]); } foreach ($batch as $row) { $id = (int)($row['id'] ?? 0); if ($id <= 0) continue; $status = (string)($row['status'] ?? ''); $topic = $status === 'trash' ? 'product.deleted' : 'product.updated'; $payload = self::buildProductSyncPayload($id, $status); self::directDeliverPayload($topic, $payload, true); $state['processed'] = (int)($state['processed'] ?? 0) + 1; $state[$status === 'trash' ? 'deleted' : 'updated'] = (int)($state[$status === 'trash' ? 'deleted' : 'updated'] ?? 0) + 1; $state['last_id'] = $id; } update_option(self::FULL_SYNC_OPTION, $state, false); self::notifySyncProgress('progress', [ 'token' => $token, 'total' => (int)($state['total'] ?? 0), 'processed' => (int)($state['processed'] ?? 0), 'updated' => (int)($state['updated'] ?? 0), 'deleted' => (int)($state['deleted'] ?? 0), ]); wp_send_json_success([ 'done' => false, 'token' => $token, 'processed' => (int)($state['processed'] ?? 0), 'updated' => (int)($state['updated'] ?? 0), 'deleted' => (int)($state['deleted'] ?? 0), 'total' => (int)($state['total'] ?? 0), ]); } catch (\Throwable $e) { self::log('error', 'full_sync_step_failed', ['error' => $e->getMessage()]); wp_send_json_error(['message' => 'exception', 'detail' => $e->getMessage()]); } } private static function apiOrigin(array $opts): string { return 'https://app.yektabot.com'; } private static function notifySyncProgress(string $event, array $state): void { try { if (!function_exists('wp_remote_post') || !function_exists('wp_json_encode')) { return; } $opts = self::opts(); if (!self::hasConnection($opts)) { return; } $payload = array_merge([ 'event' => $event, 'account_id' => (int)$opts['account_id'], 'platform' => (string)($opts['platform'] ?? ''), 'site' => home_url('/'), ], $state); if ($event === 'completed') { $payload['done'] = true; } $json = wp_json_encode($payload); if (!$json) { return; } $headers = ['Content-Type' => 'application/json']; $headers['X-Yektabot-Signature'] = base64_encode(hash_hmac('sha256', $json, (string)$opts['secret'], true)); wp_remote_post(self::apiOrigin($opts) . '/woocommerce/sync-progress', [ 'timeout' => 6, 'redirection' => 0, 'blocking' => false, 'headers' => $headers, 'body' => $json, ]); } catch (\Throwable $e) { } } private static function fullSyncTotal(): int { global $wpdb; if (!isset($wpdb) || !method_exists($wpdb, 'get_var') || !isset($wpdb->posts)) return 0; $statuses = ['publish', 'draft', 'pending', 'private', 'future', 'trash']; $placeholders = implode(',', array_fill(0, count($statuses), '%s')); $sql = "SELECT COUNT(1) FROM {$wpdb->posts} WHERE post_type = %s AND post_status IN ({$placeholders})"; $args = array_merge(['product'], $statuses); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Sync uses fresh keyset reads from the WordPress posts table; generated placeholders and all values are passed to prepare(). $count = (int)$wpdb->get_var($wpdb->prepare($sql, ...$args)); return max(0, $count); } private static function fullSyncBatch(int $afterId, int $limit): array { global $wpdb; if (!isset($wpdb) || !method_exists($wpdb, 'get_results')) return []; $limit = max(1, min(200, $limit)); $afterId = max(0, $afterId); $statuses = ['publish', 'draft', 'pending', 'private', 'future', 'trash']; $placeholders = implode(',', array_fill(0, count($statuses), '%s')); $sql = "SELECT ID, post_status FROM {$wpdb->posts} WHERE post_type = %s AND post_status IN ({$placeholders}) AND ID > %d ORDER BY ID ASC LIMIT %d"; $args = array_merge(['product'], $statuses, [$afterId, $limit]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Sync uses fresh keyset reads from the WordPress posts table; generated placeholders and all values are passed to prepare(). $rows = $wpdb->get_results($wpdb->prepare($sql, ...$args), ARRAY_A); if (!is_array($rows)) return []; return array_map(fn($r) => ['id' => (int)($r['ID'] ?? 0), 'status' => (string)($r['post_status'] ?? '')], $rows); } private static function buildProductSyncPayload(int $productId, string $postStatus = ''): array { $payload = [ 'id' => $productId, 'post_status' => $postStatus, 'status' => $postStatus !== '' ? $postStatus : 'publish', ]; if (!function_exists('wc_get_product')) { self::loadWoo(); } if (!function_exists('wc_get_product')) { return $payload; } $product = wc_get_product($productId); if (!$product) { return $payload; } $status = method_exists($product, 'get_status') ? (string)$product->get_status() : $postStatus; if ($status === '') { $status = $postStatus !== '' ? $postStatus : 'publish'; } $type = method_exists($product, 'get_type') ? (string)$product->get_type() : 'simple'; $images = []; $imageId = method_exists($product, 'get_image_id') ? (int)$product->get_image_id() : 0; if ($imageId > 0 && function_exists('wp_get_attachment_url')) { $src = wp_get_attachment_url($imageId); if ($src) { $images[] = ['id' => $imageId, 'src' => $src]; } } if (method_exists($product, 'get_gallery_image_ids') && function_exists('wp_get_attachment_url')) { foreach ((array)$product->get_gallery_image_ids() as $gid) { $gid = (int)$gid; if ($gid <= 0) { continue; } $src = wp_get_attachment_url($gid); if ($src) { $images[] = ['id' => $gid, 'src' => $src]; } } } $payload = array_merge($payload, [ 'name' => method_exists($product, 'get_name') ? (string)$product->get_name() : '', 'sku' => method_exists($product, 'get_sku') ? (string)$product->get_sku() : '', 'unique_code' => self::storedUniqueCode($productId), 'type' => $type !== '' ? $type : 'simple', 'status' => $status, 'post_status' => $status, 'price' => method_exists($product, 'get_price') ? (string)$product->get_price() : '', 'regular_price' => method_exists($product, 'get_regular_price') ? (string)$product->get_regular_price() : '', 'sale_price' => method_exists($product, 'get_sale_price') ? (string)$product->get_sale_price() : '', 'short_description' => method_exists($product, 'get_short_description') ? (string)$product->get_short_description() : '', 'description' => method_exists($product, 'get_description') ? (string)$product->get_description() : '', 'manage_stock' => method_exists($product, 'get_manage_stock') ? (bool)$product->get_manage_stock() : false, 'stock_quantity' => method_exists($product, 'get_stock_quantity') ? $product->get_stock_quantity() : null, 'stock_status' => method_exists($product, 'get_stock_status') ? (string)$product->get_stock_status() : '', 'images' => $images, ]); if ($type === 'variable' && method_exists($product, 'get_children')) { $variations = []; foreach ((array)$product->get_children() as $childId) { $childId = (int)$childId; if ($childId <= 0) { continue; } $v = wc_get_product($childId); if (!$v) { continue; } $vImages = []; $vImageId = method_exists($v, 'get_image_id') ? (int)$v->get_image_id() : 0; if ($vImageId > 0 && function_exists('wp_get_attachment_url')) { $src = wp_get_attachment_url($vImageId); if ($src) { $vImages[] = ['id' => $vImageId, 'src' => $src]; } } $attrs = []; if (method_exists($v, 'get_attributes')) { foreach ((array)$v->get_attributes() as $key => $val) { $attrs[] = [ 'name' => (string)$key, 'option' => is_array($val) ? implode(', ', $val) : (string)$val, ]; } } $variations[] = [ 'id' => $childId, 'sku' => method_exists($v, 'get_sku') ? (string)$v->get_sku() : '', 'name' => self::variationAttributeLabel($v), 'price' => method_exists($v, 'get_price') ? (string)$v->get_price() : '', 'regular_price' => method_exists($v, 'get_regular_price') ? (string)$v->get_regular_price() : '', 'sale_price' => method_exists($v, 'get_sale_price') ? (string)$v->get_sale_price() : '', 'manage_stock' => method_exists($v, 'get_manage_stock') ? (bool)$v->get_manage_stock() : false, 'stock_quantity' => method_exists($v, 'get_stock_quantity') ? $v->get_stock_quantity() : null, 'stock_status' => method_exists($v, 'get_stock_status') ? (string)$v->get_stock_status() : '', 'attributes' => $attrs, 'image' => $vImages[0] ?? null, 'images' => $vImages, ]; } $payload['variations'] = $variations; } return $payload; } private static function directDeliverPayload(string $topic, array $payload, bool $blocking = true): void { $opts = self::opts(); if (!self::hasConnection($opts)) { self::log('warn', 'direct_delivery_skipped', ['reason' => 'not_connected', 'topic' => $topic]); return; } if (!function_exists('wp_remote_post')) return; $url = self::target($opts); $body = wp_json_encode($payload); if (!$body) return; $sig = base64_encode(hash_hmac('sha256', $body, (string)$opts['secret'], true)); $headers = [ 'Content-Type' => 'application/json', 'X-WC-Webhook-Topic' => $topic, 'X-WC-Webhook-Signature' => $sig, 'X-WC-Webhook-Source' => home_url('/'), ]; $args = [ 'timeout' => $blocking ? 15 : 0.01, 'blocking' => $blocking, 'headers' => $headers, 'body' => $body, 'redirection' => 0, ]; $resp = wp_remote_post($url, $args); if ($blocking) { if (is_wp_error($resp)) { self::log('error', 'direct_delivery', ['topic' => $topic, 'error' => $resp->get_error_message()]); return; } $code = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($resp) : null; self::log(($code >= 200 && $code < 300) ? 'info' : 'warn', 'direct_delivery', ['topic' => $topic, 'status' => $code, 'url' => $url]); } } private static function cleanupByUrl(string $url): void { self::loadWoo(); $table = self::webhookTable(); if ($table) { global $wpdb; $like = $wpdb->esc_like('Yektabot ') . '%'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Connection cleanup requires immediate writes to the trusted prefix/literal WooCommerce tables; all filter values are prepared and no plugin object cache is used. $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE name LIKE %s AND delivery_url = %s", $like, $url)); } if (class_exists('\WC_Webhooks') && method_exists('\WC_Webhooks', 'get_webhooks')) { foreach (self::webhooks() as $hook) { if ($hook->get_name() === 'Yektabot ' . $hook->get_topic() && $hook->get_delivery_url() === $url) $hook->delete(true); } } else { $ids = get_posts(['post_type' => 'shop_webhook', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids']); foreach ($ids as $id) { $title = (string)get_the_title((int)$id); if (!str_starts_with($title, 'Yektabot ')) continue; $delivery = (string)(get_post_meta((int)$id, '_webhook_delivery_url', true) ?: get_post_meta((int)$id, 'webhook_delivery_url', true)); if ($delivery !== $url) continue; wp_delete_post((int)$id, true); } } delete_option(self::OPTION . '_synced'); } private static function loadWoo(): void { if (!function_exists('WC')) return; try { WC(); } catch (\Throwable $e) {} } private static function webhookTable(): ?string { if (self::$webhookTable !== null) return self::$webhookTable ?: null; global $wpdb; if (!isset($wpdb) || !isset($wpdb->prefix)) return self::$webhookTable = ''; foreach ([$wpdb->prefix . 'wc_webhooks', $wpdb->prefix . 'woocommerce_webhooks'] as $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Legacy table discovery uses a prepared literal-suffix table name and caches the result in the static webhookTable property. $found = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); if ($found === $table) return self::$webhookTable = $table; } return self::$webhookTable = ''; } private static function webhooks(): array { return method_exists('\WC_Webhooks', 'get_webhooks') ? \WC_Webhooks::get_webhooks() : []; } private static function log(string $level, string $message, array $context): void { if (empty(self::opts()['logs_enabled'])) return; foreach (['secret', 'consumer_key', 'consumer_secret', 'zarinpal_merchant_id', 'zibal_merchant_id', 'token'] as $key) { if (array_key_exists($key, $context)) $context[$key] = '[redacted]'; } $logs = self::logs(); $logs[] = ['time' => current_time('mysql'), 'level' => $level, 'message' => $message, 'context' => $context]; if (count($logs) > 50) $logs = array_slice($logs, -50); update_option(self::LOG_OPTION, $logs, false); } private static function logs(): array { $value = get_option(self::LOG_OPTION, []); return is_array($value) ? $value : []; } public static function sync(bool $validated = false): void { if (!self::ready(true)) return; $opts = self::opts(); foreach (self::TOPICS as $topic) self::ensure($topic, $opts); update_option(self::OPTION . '_synced', (string)self::signature()); self::log('info', 'synced', ['topics' => self::TOPICS, 'url' => self::target($opts)]); } private static function ready(bool $log = false): bool { self::loadWoo(); $opts = self::opts(); if (!self::hasConnection($opts)) { if ($log) self::log('warn', 'config_missing', []); return false; } $woo = class_exists('\WooCommerce') || function_exists('WC'); if (!$woo) { if ($log) self::log('error', 'woocommerce_inactive', []); return false; } if (class_exists('\WC_Webhook') && class_exists('\WC_Webhooks')) return true; $table = self::webhookTable(); if ($table) { if ($log) self::log('warn', 'fallback_db', ['wc_version' => defined('WC_VERSION') ? WC_VERSION : null, 'table' => $table]); return true; } if ($log) self::log('warn', 'fallback_cpt', ['wc_version' => defined('WC_VERSION') ? WC_VERSION : null]); return true; } private static function ensure(string $topic, array $opts): void { self::loadWoo(); try { $url = self::target($opts); if (!class_exists('\WC_Webhook') || !class_exists('\WC_Webhooks')) { $table = self::webhookTable(); if ($table) { self::ensureTable($table, $topic, $url, $opts['secret']); return; } self::ensureCpt($topic, $url, $opts['secret']); return; } $name = 'Yektabot ' . $topic; $matches = []; foreach (self::webhooks() as $hook) { if ($hook->get_topic() !== $topic) continue; $hookName = $hook->get_name(); if (!$hookName || !str_starts_with($hookName, 'Yektabot ')) continue; $matches[] = $hook; } $existing = null; foreach ($matches as $hook) { if ($hook->get_name() === $name) { $existing = $hook; break; } } if (!$existing && $matches) $existing = $matches[0]; if ($existing) { foreach ($matches as $hook) if ($hook !== $existing) $hook->delete(true); $existing->set_name($name); $existing->set_delivery_url($url); $existing->set_secret($opts['secret']); $existing->set_status('active'); $existing->save(); self::log('info', 'webhook_updated', ['topic' => $topic, 'url' => $url]); return; } $hook = new \WC_Webhook(); $hook->set_name($name); $hook->set_topic($topic); $hook->set_delivery_url($url); $hook->set_secret($opts['secret']); $hook->set_status('active'); $hook->save(); self::log('info', 'webhook_created', ['topic' => $topic, 'url' => $url]); } catch (\Throwable $e) { self::log('error', 'webhook_failed', ['topic' => $topic, 'error' => $e->getMessage()]); } } private static function ensureTable(string $table, string $topic, string $url, string $secret): void { global $wpdb; $now = current_time('mysql'); $nowGmt = current_time('mysql', 1); $name = 'Yektabot ' . $topic; $userId = get_current_user_id() ?: 0; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fallback only when WooCommerce webhook classes are unavailable; table comes from webhookTable() trusted names, values are prepared, and reads must reflect the immediately following writes. $ids = $wpdb->get_col($wpdb->prepare("SELECT webhook_id FROM {$table} WHERE topic = %s AND name = %s ORDER BY webhook_id DESC", $topic, $name)); if (empty($ids)) { $like = method_exists($wpdb, 'esc_like') ? ($wpdb->esc_like('Yektabot ') . '%') : 'Yektabot %'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fallback only when WooCommerce webhook classes are unavailable; table comes from webhookTable() trusted names, values are prepared, and reads must reflect the immediately following writes. $ids = $wpdb->get_col($wpdb->prepare("SELECT webhook_id FROM {$table} WHERE topic = %s AND name LIKE %s ORDER BY webhook_id DESC", $topic, $like)); } $id = !empty($ids) ? (int)$ids[0] : 0; if ($id > 0) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fallback only when WooCommerce webhook classes are unavailable; table comes from webhookTable() trusted names, values are prepared, and reads must reflect the immediately following writes. $wpdb->update($table, [ 'status' => 'active', 'name' => $name, 'user_id' => $userId, 'delivery_url' => $url, 'secret' => $secret, 'topic' => $topic, 'date_modified' => $now, 'date_modified_gmt' => $nowGmt, 'api_version' => 3, // must be 0 when idle; WooCommerce sets it to 1 only while delivering. 'pending_delivery' => 0, ], ['webhook_id' => $id]); foreach (array_slice($ids, 1) as $dupId) { $dupId = (int)$dupId; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Fallback only when WooCommerce webhook classes are unavailable; table comes from webhookTable() trusted names, values are prepared, and reads must reflect the immediately following writes. if ($dupId > 0) $wpdb->delete($table, ['webhook_id' => $dupId], ['%d']); } self::log('info', 'webhook_updated', ['topic' => $topic, 'url' => $url, 'engine' => 'table', 'id' => $id]); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Fallback only when WooCommerce webhook classes are unavailable; table comes from webhookTable() trusted names, values are prepared, and reads must reflect the immediately following writes. $wpdb->insert($table, [ 'status' => 'active', 'name' => $name, 'user_id' => $userId, 'delivery_url' => $url, 'secret' => $secret, 'topic' => $topic, 'date_created' => $now, 'date_created_gmt' => $nowGmt, 'date_modified' => $now, 'date_modified_gmt' => $nowGmt, 'api_version' => 3, 'failure_count' => 0, 'pending_delivery' => 0, ]); self::log('info', 'webhook_created', ['topic' => $topic, 'url' => $url, 'engine' => 'table', 'id' => (int)($wpdb->insert_id ?? 0)]); } private static function ensureCpt(string $topic, string $url, string $secret): void { $ids = self::findYektabotWebhookPosts($topic); $id = !empty($ids) ? (int)$ids[0] : 0; if ($id > 0) { self::updateWebhookPost($id, $topic, $url, $secret); foreach (array_slice($ids, 1) as $dupId) wp_delete_post((int)$dupId, true); self::log('info', 'webhook_updated', ['topic' => $topic, 'url' => $url, 'engine' => 'cpt', 'id' => $id]); return; } $id = wp_insert_post([ 'post_type' => 'shop_webhook', 'post_status' => 'publish', 'post_title' => 'Yektabot ' . $topic, 'post_author' => get_current_user_id() ?: 0, ], true); if (is_wp_error($id)) { self::log('error', 'webhook_failed', ['topic' => $topic, 'engine' => 'cpt', 'error' => $id->get_error_message()]); return; } $id = (int)$id; if ($id <= 0) { self::log('error', 'webhook_failed', ['topic' => $topic, 'engine' => 'cpt', 'error' => 'invalid_id']); return; } self::updateWebhookPost($id, $topic, $url, $secret); self::log('info', 'webhook_created', ['topic' => $topic, 'url' => $url, 'engine' => 'cpt', 'id' => $id]); } private static function findYektabotWebhookPosts(string $topic): array { $title = 'Yektabot ' . $topic; $ids = get_posts([ 'post_type' => 'shop_webhook', 'post_status' => 'any', 'posts_per_page' => 50, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'DESC', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Legacy WooCommerce webhook compatibility only; constrained to shop_webhook, one topic, and at most 50 IDs. 'meta_query' => [['key' => '_webhook_topic', 'value' => $topic, 'compare' => '=']], ]); $ids = array_values(array_filter($ids, fn($id) => (($t = (string)get_the_title((int)$id)) === $title) || str_starts_with($t, 'Yektabot '))); if (!empty($ids)) return $ids; $ids = get_posts([ 'post_type' => 'shop_webhook', 'post_status' => 'any', 'posts_per_page' => 50, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'DESC', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Legacy WooCommerce webhook compatibility only; constrained to shop_webhook, one topic, and at most 50 IDs. 'meta_query' => [['key' => 'webhook_topic', 'value' => $topic, 'compare' => '=']], ]); return array_values(array_filter($ids, fn($id) => (($t = (string)get_the_title((int)$id)) === $title) || str_starts_with($t, 'Yektabot '))); } private static function updateWebhookPost(int $id, string $topic, string $url, string $secret): void { wp_update_post(['ID' => $id, 'post_title' => 'Yektabot ' . $topic, 'post_status' => 'publish']); $userId = get_current_user_id() ?: 0; $pairs = [ '_webhook_topic' => $topic, '_webhook_delivery_url' => $url, '_webhook_secret' => $secret, '_webhook_status' => 'active', '_webhook_user_id' => $userId, '_webhook_api_version' => 3, ]; foreach ($pairs as $key => $value) update_post_meta($id, $key, $value); $pairs = [ 'webhook_topic' => $topic, 'webhook_delivery_url' => $url, 'webhook_secret' => $secret, 'webhook_status' => 'active', ]; foreach ($pairs as $key => $value) update_post_meta($id, $key, $value); } private static function signature(): string { $opts = self::opts(); return md5(json_encode($opts)); } private static function clearLogs(): void { update_option(self::LOG_OPTION, [], false); } private static function readyDetails(): array { self::loadWoo(); $opts = self::opts(); $checks = []; $woo = class_exists('\WooCommerce') || function_exists('WC'); $version = defined('WC_VERSION') ? WC_VERSION : null; $classes = class_exists('\WC_Webhook') && class_exists('\WC_Webhooks'); $table = self::webhookTable(); $registered = function_exists('post_type_exists') ? post_type_exists('shop_webhook') : null; $hasAny = !empty(get_posts(['post_type' => 'shop_webhook', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids'])); $engine = $woo ? ($classes ? 'classes' : ($table ? 'table' : 'cpt')) : 'none'; $checks[] = ['label' => 'ووکامرس', 'ok' => $woo, 'detail' => $woo ? ('فعال' . ($version ? " (نسخه $version)" : '')) : 'پیدا نشد یا فعال نیست']; $checks[] = ['label' => 'موتور وبهوک', 'ok' => $engine !== 'none', 'detail' => $engine === 'classes' ? 'کلاس‌های ووکامرس' : ($engine === 'table' ? 'جدول دیتابیس (wc_webhooks)' : ($engine === 'cpt' ? 'پست‌تایپ (shop_webhook)' : 'در دسترس نیست'))]; $checks[] = ['label' => 'جدول wc_webhooks', 'ok' => (bool)$table, 'detail' => $table ?: 'وجود ندارد']; $checks[] = ['label' => 'آدرس وبهوک', 'ok' => (bool)$opts['url'], 'detail' => $opts['url'] ?: 'ثبت نشده']; $checks[] = ['label' => 'کلید امنیتی', 'ok' => (bool)$opts['secret'], 'detail' => $opts['secret'] ? 'تنظیم شده' : 'ثبت نشده']; return $checks; } private static function installId(): string { $state = get_option(self::TRACK_OPTION, []); $state = is_array($state) ? $state : []; if (empty($state['id'])) { $state['id'] = function_exists('random_bytes') ? bin2hex(random_bytes(16)) : (function_exists('wp_generate_password') ? wp_generate_password(32, false) : md5(uniqid('', true))); $state['created_at'] = time(); update_option(self::TRACK_OPTION, $state, false); } update_option(self::INSTALL_ID_OPTION, $state['id'], false); return (string)$state['id']; } private static function mask(string $value, int $visible): string { $value = trim($value); $len = strlen($value); if ($len <= $visible) return str_repeat('•', max(0, $len - 1)) . substr($value, -1); return str_repeat('•', max(8, $len - $visible)) . substr($value, -$visible); } private static function maskUrl(string $url, string $accountId): string { $p = wp_parse_url($url); $host = $p['host'] ?? 'app.yektabot.com'; $path = $p['path'] ?? '/woocommerce/webhook'; $shortPath = '/…' . (str_ends_with($path, '/webhook') ? '/webhook' : ''); $suffix = $accountId ? ('?account_id=' . self::mask($accountId, 2)) : ''; return $host . $shortPath . $suffix; } private static function savePaymentSettings(): void { check_admin_referer('yb_payment'); $opts = self::opts(); $payload = [ 'payment_gateway_default' => isset($_POST['payment_gateway_default']) ? sanitize_text_field(wp_unslash($_POST['payment_gateway_default'])) : '', 'payment_enabled' => !empty($_POST['payment_enabled']) ? '1' : '0', 'payment_merchant_source' => 'auto', ]; update_option(self::OPTION, self::sanitize(array_merge($opts, $payload)), false); self::syncPaymentSettingsFromWoo(true); self::sendServiceSettings('heartbeat'); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&payment=1')); exit; } private static function syncPaymentSettings(): void { self::syncPaymentSettingsFromWoo(true); self::sendServiceSettings('heartbeat'); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&payment_synced=1')); exit; } public static function maybeSyncPaymentSettings(): void { if (!is_admin() || !current_user_can('manage_options')) return; $state = get_option(self::PAYMENT_SYNC_OPTION, []); $state = is_array($state) ? $state : []; $last = (int)($state['last'] ?? 0); if ($last > 0 && (time() - $last) < 21600) return; self::syncPaymentSettingsFromWoo(false); } private static function syncPaymentSettingsFromWoo(bool $force): void { try { self::loadWoo(); $opts = self::opts(); $detected = ['zarinpal_merchant_id' => '', 'zibal_merchant_id' => '']; $defaultGateway = function_exists('get_option') ? (string)get_option('woocommerce_default_gateway', '') : ''; if (class_exists('\WC') && function_exists('WC')) { try { $gws = WC()->payment_gateways && method_exists(WC()->payment_gateways, 'payment_gateways') ? WC()->payment_gateways->payment_gateways() : []; if (is_array($gws)) { foreach ($gws as $id => $gw) { $id = (string)$id; $probe = strtolower($id . ' ' . (is_object($gw) && property_exists($gw, 'method_title') ? (string)$gw->method_title : '') . ' ' . (is_object($gw) && property_exists($gw, 'title') ? (string)$gw->title : '')); $kind = (str_contains($probe, 'zarin') || str_contains($probe, 'زرین')) ? 'zarinpal' : ((str_contains($probe, 'zibal') || str_contains($probe, 'زیبال')) ? 'zibal' : null); $s = get_option('woocommerce_' . $id . '_settings', []); if (!is_array($s)) continue; if (!$kind) { if (isset($s['zibal_merchant']) || isset($s['zibal_merchant_id']) || $id === 'wc_zibal' || $id === 'zibal' || $id === 'zibal_gateway') $kind = 'zibal'; elseif (isset($s['zarinpal_merchant']) || isset($s['merchantID']) || $id === 'zarinpal' || $id === 'zarinpal_gateway') $kind = 'zarinpal'; } if (!$kind) continue; $keys = $kind === 'zarinpal' ? ['merchantcode', 'merchant_id', 'merchantId', 'MerchantID', 'merchant', 'merchantCode', 'MerchantCode', 'MERCHANT_ID', 'MERCHANTCODE', 'zarinpal_merchant_id'] : ['pin', 'merchant', 'merchant_id', 'merchantId', 'MerchantID', 'zibal_merchant', 'zibal_merchant_id', 'merchant_code', 'zibal_merchant_code', 'merchantcode', 'merchantCode', 'MerchantCode', 'MERCHANT', 'MERCHANT_ID', 'MERCHANTCODE', 'apikey', 'api_key', 'apiKey', 'terminal', 'terminal_id', 'terminalId', 'MerchantId', 'merchant-id', 'merchant-code', 'terminal-id', 'zibal_terminal', 'terminal_code', 'merchant_key']; $found = false; foreach ($keys as $k) { $v = isset($s[$k]) ? trim((string)$s[$k]) : ''; if (!$v) continue; $detected[$kind === 'zarinpal' ? 'zarinpal_merchant_id' : 'zibal_merchant_id'] = $v; $found = true; break; } if ($force && !$found) { self::log('info', 'payment_sync_no_key', ['gateway' => $id, 'kind' => $kind, 'available_keys' => array_keys($s)]); } } } } catch (\Throwable $e) { } } $map = [ 'zarinpal' => [ 'options' => ['woocommerce_zarinpal_settings', 'woocommerce_wc_zarinpal_settings', 'woocommerce_persian_gateway_zarinpal_settings', 'woocommerce_zarinpalgateway_settings', 'woocommerce_WC_ZPal_settings'], 'keys' => ['merchantcode', 'merchant_id', 'merchantId', 'MerchantID', 'merchant', 'merchantCode', 'zarinpal_merchant_id'], ], 'zibal' => [ 'options' => ['woocommerce_WC_Zibal_settings', 'woocommerce_zibal_settings', 'woocommerce_wc_zibal_settings', 'woocommerce_zibalgateway_settings', 'woocommerce_zibal_gateway_settings', 'woocommerce_Zibal_Gateway_settings', 'woocommerce_zibal-gateway_settings', 'woocommerce_zibal-payment-gateway_settings', 'woocommerce_persian_gateway_zibal_settings', 'zibal_settings', 'woocommerce_zibal_payment_settings'], 'keys' => ['pin', 'merchant', 'merchant_id', 'merchantId', 'MerchantID', 'zibal_merchant', 'zibal_merchant_id', 'merchant_code', 'zibal_merchant_code', 'merchantcode', 'merchantCode', 'MerchantCode', 'MERCHANT', 'MERCHANT_ID', 'MERCHANTCODE', 'apikey', 'api_key', 'apiKey', 'terminal', 'terminal_id', 'terminalId', 'MerchantId', 'merchant-id', 'merchant-code', 'terminal-id', 'zibal_terminal', 'terminal_code', 'merchant_key'], ], ]; foreach ($map as $gateway => $cfg) { if ($gateway === 'zarinpal' && $detected['zarinpal_merchant_id']) continue; if ($gateway === 'zibal' && $detected['zibal_merchant_id']) continue; foreach ($cfg['options'] as $optKey) { $s = get_option($optKey, []); if (!is_array($s)) continue; foreach ($cfg['keys'] as $k) { $v = isset($s[$k]) ? trim((string)$s[$k]) : ''; if (!$v) continue; $detected[$gateway === 'zarinpal' ? 'zarinpal_merchant_id' : 'zibal_merchant_id'] = $v; break 2; } } } // Direct options check as fallback if (!$detected['zarinpal_merchant_id']) { foreach (['zarinpal_merchant_id', 'zarinpal_merchant', 'zpal_merchant_id', 'zarinpal_code'] as $opt) { $v = trim((string)get_option($opt, '')); if ($v) { $detected['zarinpal_merchant_id'] = $v; break; } } } if (!$detected['zibal_merchant_id']) { foreach (['zibal_merchant_id', 'zibal_merchant', 'zibal_merchant_code', 'zibal_terminal', 'zibal_apikey'] as $opt) { $v = trim((string)get_option($opt, '')); if ($v) { $detected['zibal_merchant_id'] = $v; break; } } } $gatewayDefault = ''; if ($defaultGateway) { if (str_contains($defaultGateway, 'zarin')) $gatewayDefault = 'zarinpal'; if (str_contains($defaultGateway, 'zibal')) $gatewayDefault = 'zibal'; } $next = array_merge($opts, [ 'zarinpal_merchant_id' => (string)$detected['zarinpal_merchant_id'], 'zibal_merchant_id' => (string)$detected['zibal_merchant_id'], 'payment_merchant_source' => 'auto', ]); if ($force || empty($next['payment_gateway_default'])) $next['payment_gateway_default'] = (string)$gatewayDefault; update_option(self::OPTION, self::sanitize($next), false); update_option(self::PAYMENT_SYNC_OPTION, ['last' => time()], false); } catch (\Throwable $e) { } } public static function maybePaymentBridge(): void { if (is_admin()) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. if (!isset($_GET['yektabot_pay']) || !is_string($_GET['yektabot_pay']) || $_GET['yektabot_pay'] !== '1') return; self::renderPaymentBridge(); exit; } private static function renderPaymentBridge(): void { if (function_exists('nocache_headers')) nocache_headers(); header('Content-Type: text/html; charset=utf-8'); header('Referrer-Policy: no-referrer'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. $token = isset($_GET['yb_token']) && is_string($_GET['yb_token']) ? sanitize_text_field(wp_unslash($_GET['yb_token'])) : ''; if (!$token) { self::renderPayResult(false, 'پرداخت', 'لینک پرداخت نامعتبر است.'); return; } $opts = self::opts(); $secret = (string)($opts['secret'] ?? ''); if (!self::hasConnection($opts)) { self::renderPayResult(false, 'پرداخت', 'اتصال افزونه به یکتابات انجام نشده است.'); return; } [$payload, $payloadJson, $sig] = self::decodeSignedToken($token); if (!$payload || !$payloadJson || !$sig) { self::renderPayResult(false, 'پرداخت', 'لینک پرداخت نامعتبر است.'); return; } $expected = hash_hmac('sha256', $payloadJson, $secret, true); if (!hash_equals($expected, $sig)) { self::renderPayResult(false, 'پرداخت', 'لینک پرداخت نامعتبر است.'); return; } $site = isset($payload['site']) ? rtrim((string)$payload['site'], '/') : ''; $home = rtrim(home_url('/'), '/'); $siteHost = $site ? (string)wp_parse_url($site, PHP_URL_HOST) : ''; $homeHost = (string)wp_parse_url($home, PHP_URL_HOST); if ($siteHost && !hash_equals(strtolower($homeHost), strtolower($siteHost))) { self::renderPayResult(false, 'پرداخت', 'این لینک پرداخت برای این سایت صادر نشده است.'); return; } $site = $home; $exp = isset($payload['exp']) ? (int)$payload['exp'] : 0; if ($exp > 0 && (time() - 30) > $exp) { self::renderPayResult(false, 'پرداخت', 'این لینک منقضی شده است.'); return; } $orderId = (int)($payload['order_id'] ?? 0); $amount = (int)($payload['amount'] ?? 0); $payloadAccount = (string)($payload['account_id'] ?? ''); if ($orderId <= 0 || $amount <= 0) { self::renderPayResult(false, 'پرداخت', 'لینک پرداخت نامعتبر است.'); return; } if (!empty($opts['account_id']) && $payloadAccount && !hash_equals((string)$opts['account_id'], $payloadAccount)) { self::renderPayResult(false, 'پرداخت', 'لینک پرداخت نامعتبر است.'); return; } $gateway = self::resolvePaymentGateway($payload, $opts); if (!$gateway) { self::renderPayResult(false, 'پرداخت', 'لینک پرداخت نامعتبر است.'); return; } $merchant = $gateway === 'zarinpal' ? trim((string)($opts['zarinpal_merchant_id'] ?? '')) : trim((string)($opts['zibal_merchant_id'] ?? '')); if (!$merchant) { self::renderPayResult(false, 'پرداخت', 'کلید درگاه پرداخت تنظیم نشده است.'); return; } $base = $site ?: (function_exists('home_url') ? rtrim((string)home_url('/'), '/') : ''); $callbackUrl = rtrim($base, '/') . '/?yektabot_pay=1&yb_token=' . rawurlencode($token); $amountRial = $amount * 10; $transactionKey = 'yektabot_pay_tx_' . hash('sha256', $token); $transactionTtl = $exp > 0 ? max(60, $exp - time() + 60) : DAY_IN_SECONDS; $transaction = get_transient($transactionKey); $transaction = is_array($transaction) ? $transaction : []; $merchantHash = hash('sha256', $merchant); $platform = (string)($payload['platform'] ?? ($opts['platform'] ?? '')); $accountUsername = (string)($payload['account_username'] ?? ($opts['account_username'] ?? ($payload['ig_username'] ?? ($opts['ig_username'] ?? '')))); $isCallback = $gateway === 'zarinpal' // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. ? (isset($_GET['Authority']) || isset($_GET['Status'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. : isset($_GET['trackId']); if (!$isCallback) { if (!empty($transaction['paid'])) { self::renderPayResult(true, 'پرداخت', 'پرداخت با موفقیت انجام شد.', $orderId, $amount, $accountUsername, $platform); return; } if (($transaction['gateway'] ?? '') === $gateway && ($transaction['merchant_hash'] ?? '') === $merchantHash && (int)($transaction['amount_rial'] ?? 0) === $amountRial && !empty($transaction['checkout_url'])) { self::redirectToService((string)$transaction['checkout_url']); exit; } $trace = substr(hash('sha256', $token), 0, 16); $url = $gateway === 'zarinpal' ? self::zarinpalRequest($merchant, $amountRial, $callbackUrl, $orderId, (string)($payload['mobile'] ?? ''), $trace) : self::zibalRequest($merchant, $amountRial, $callbackUrl, $trace); if ($url) { $reference = rawurldecode(basename((string)wp_parse_url($url, PHP_URL_PATH))); $transaction = [ 'gateway' => $gateway, 'reference' => $reference, 'amount_rial' => $amountRial, 'merchant_hash' => $merchantHash, 'checkout_url' => $url, 'paid' => false, ]; if (!set_transient($transactionKey, $transaction, $transactionTtl)) { self::renderPayResult(false, 'پرداخت', 'ذخیره اطلاعات تراکنش انجام نشد. لطفاً دوباره تلاش کنید.', $orderId, $amount); return; } self::redirectToService($url); exit; } $hint = ''; if (function_exists('get_transient')) { $last = get_transient('yektabot_pay_err_' . $trace); if (is_array($last) && !empty($last['message'])) $hint = ' (' . (string)$last['message'] . ')'; } self::renderPayResult(false, 'پرداخت', 'خطا در اتصال به درگاه پرداخت. لطفاً دوباره تلاش کنید.' . $hint, $orderId, $amount); return; } $referenceKey = $gateway === 'zarinpal' ? 'Authority' : 'trackId'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. $callbackReference = isset($_GET[$referenceKey]) && is_string($_GET[$referenceKey]) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. ? sanitize_text_field(wp_unslash($_GET[$referenceKey])) : ''; if ($callbackReference === '' || empty($transaction['reference']) || !hash_equals((string)$transaction['reference'], $callbackReference) || ($transaction['gateway'] ?? '') !== $gateway || (int)($transaction['amount_rial'] ?? 0) !== $amountRial || !hash_equals((string)($transaction['merchant_hash'] ?? ''), $merchantHash)) { self::renderPayResult(false, 'پرداخت', 'شناسه تراکنش با این لینک پرداخت مطابقت ندارد.', $orderId, $amount, $accountUsername, $platform); return; } if (!empty($transaction['paid'])) { self::confirmPaymentToYektabot($token, 'paid', $gateway, (string)($transaction['ref'] ?? $callbackReference), (array)($transaction['meta'] ?? [])); self::renderPayResult(true, 'پرداخت', 'پرداخت با موفقیت انجام شد.', $orderId, $amount, $accountUsername, $platform); return; } if ($gateway === 'zarinpal') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. $authority = isset($_GET['Authority']) ? sanitize_text_field(wp_unslash($_GET['Authority'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. $status = isset($_GET['Status']) ? sanitize_text_field(wp_unslash($_GET['Status'])) : ''; if (!$authority || strtoupper($status) !== 'OK') { self::confirmPaymentToYektabot($token, 'cancelled', 'zarinpal', $authority ?: null, ['status' => $status ?: 'CANCELLED']); self::renderPayResult(false, 'پرداخت', 'پرداخت لغو شد.', $orderId, $amount, $accountUsername, $platform); return; } $verified = self::zarinpalVerify($merchant, $authority, $amountRial); $ok = !empty($verified['ok']); $ref = isset($verified['ref']) ? (string)$verified['ref'] : $authority; if ($ok) { $transaction['paid'] = true; $transaction['ref'] = $ref; $transaction['meta'] = ['authority' => $authority, 'verify' => $verified]; set_transient($transactionKey, $transaction, $transactionTtl); } self::confirmPaymentToYektabot($token, $ok ? 'paid' : 'failed', 'zarinpal', $ref ?: null, ['authority' => $authority, 'verify' => $verified]); self::renderPayResult($ok, 'پرداخت', $ok ? 'پرداخت با موفقیت انجام شد.' : 'پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.', $orderId, $amount, $accountUsername, $platform); return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The external payment flow authenticates the signed token, account, expiry, and bound transaction reference; it cannot use a WordPress nonce. $trackId = isset($_GET['trackId']) ? sanitize_text_field(wp_unslash($_GET['trackId'])) : ''; if (!$trackId) { self::confirmPaymentToYektabot($token, 'cancelled', 'zibal', null, ['status' => 'CANCELLED']); self::renderPayResult(false, 'پرداخت', 'پرداخت لغو شد.', $orderId, $amount, $accountUsername, $platform); return; } $verified = self::zibalVerify($merchant, $trackId); $ok = !empty($verified['ok']); if ($ok) { $transaction['paid'] = true; $transaction['ref'] = $trackId; $transaction['meta'] = ['trackId' => $trackId, 'verify' => $verified]; set_transient($transactionKey, $transaction, $transactionTtl); } self::confirmPaymentToYektabot($token, $ok ? 'paid' : 'failed', 'zibal', $trackId, ['trackId' => $trackId, 'verify' => $verified]); self::renderPayResult($ok, 'پرداخت', $ok ? 'پرداخت با موفقیت انجام شد.' : 'پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.', $orderId, $amount, $accountUsername, $platform); } private static function resolvePaymentGateway(array $payload, array $opts): ?string { if (empty($opts['payment_enabled'])) return null; $requested = isset($payload['gateway']) ? (string)$payload['gateway'] : ''; if (in_array($requested, ['zarinpal', 'zibal'], true)) { $idKey = $requested === 'zarinpal' ? 'zarinpal_merchant_id' : 'zibal_merchant_id'; if (!empty($opts[$idKey])) return $requested; } $default = isset($opts['payment_gateway_default']) ? (string)$opts['payment_gateway_default'] : ''; if (in_array($default, ['zarinpal', 'zibal'], true)) { $idKey = $default === 'zarinpal' ? 'zarinpal_merchant_id' : 'zibal_merchant_id'; if (!empty($opts[$idKey])) return $default; } if (!empty($opts['zarinpal_merchant_id'])) return 'zarinpal'; if (!empty($opts['zibal_merchant_id'])) return 'zibal'; return null; } private static function connectionStatus(): ?bool { if (!function_exists('get_transient') || !function_exists('set_transient') || !function_exists('wp_remote_post') || !function_exists('wp_remote_retrieve_response_code') || !function_exists('wp_json_encode')) return null; $opts = self::opts(); if (!self::hasConnection($opts)) return null; $cache = get_transient(self::CONNECTION_OK_TRANSIENT); if ($cache === '1') return true; if ($cache === '0') return false; $body = wp_json_encode(['site' => home_url('/'), 'version' => self::VERSION, 'install_id' => self::installId()]); if (!$body) return null; $headers = ['Accept' => 'application/json', 'Content-Type' => 'application/json', 'X-Yektabot-Signature' => base64_encode(hash_hmac('sha256', (string)$body, (string)$opts['secret'], true))]; $resp = wp_remote_post(self::REQUIRE_URL, ['timeout' => 6, 'redirection' => 0, 'headers' => $headers, 'body' => $body]); if (is_wp_error($resp)) return null; $code = (int)wp_remote_retrieve_response_code($resp); if ($code === 200) { set_transient(self::CONNECTION_OK_TRANSIENT, '1', MINUTE_IN_SECONDS * 2); return true; } if (in_array($code, [401, 403, 404], true)) { set_transient(self::CONNECTION_OK_TRANSIENT, '0', MINUTE_IN_SECONDS * 2); return false; } return null; } private static function disconnect(): void { $opts = self::opts(); self::sendServiceSettings('disconnected'); self::cleanup(); delete_option(self::OPTION); delete_option(self::CONNECT_OPTION); delete_option(self::FULL_SYNC_OPTION); delete_option(self::PAYMENT_SYNC_OPTION); delete_option(self::TRACK_OPTION); delete_option(self::INSTALL_ID_OPTION); delete_option('yektabot_wc_license'); delete_transient(self::CONNECTION_OK_TRANSIENT); wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE . '&disconnected=1')); exit; } private static function switchHtml(string $name, bool $checked, string $label): string { static $i = 0; $i++; $id = 'yb-' . preg_replace('/[^a-z0-9_\-]/i', '', $name) . '-' . $i; return ''; } private static function confirmPaymentToYektabot(string $token, string $status, string $gateway, ?string $ref, array $meta): void { try { if (!function_exists('wp_remote_post') || !function_exists('wp_json_encode')) return; $opts = self::opts(); if (!self::hasConnection($opts)) return; $body = wp_json_encode([ 'token' => $token, 'status' => $status, 'gateway' => $gateway, 'ref' => $ref, 'meta' => $meta, ]); if (!$body) return; $sig = base64_encode(hash_hmac('sha256', $body, (string)$opts['secret'], true)); wp_remote_post(self::PAYMENT_CONFIRM_URL, [ 'timeout' => 15, 'redirection' => 0, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-Yektabot-Signature' => $sig, ], 'body' => $body, ]); } catch (\Throwable $e) { } } private static function zarinpalRequest(string $merchantId, int $amountRial, string $callbackUrl, int $orderId, string $mobile, string $trace = ''): ?string { if (!function_exists('wp_remote_post') || !function_exists('wp_json_encode') || !function_exists('wp_remote_retrieve_body')) return null; $body = wp_json_encode([ 'merchant_id' => $merchantId, 'amount' => $amountRial, 'callback_url' => $callbackUrl, 'description' => 'payment for order: ' . $orderId, 'metadata' => array_filter(['email' => null, 'mobile' => $mobile ?: null], fn($v) => $v !== null && $v !== ''), ]); if (!$body) return null; $res = wp_remote_post('https://api.zarinpal.com/pg/v4/payment/request.json', [ 'timeout' => 15, 'redirection' => 0, 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 'body' => $body, ]); if (is_wp_error($res)) { self::log('error', 'payment_gateway_request_failed', ['gateway' => 'zarinpal', 'order_id' => $orderId, 'amount_rial' => $amountRial, 'error' => $res->get_error_message()]); if ($trace && function_exists('set_transient')) set_transient('yektabot_pay_err_' . $trace, ['message' => $res->get_error_message()], 90); return null; } $http = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($res) : null; $json = json_decode((string)wp_remote_retrieve_body($res), true); $code = is_array($json) ? (int)($json['data']['code'] ?? 0) : 0; $authority = is_array($json) ? (string)($json['data']['authority'] ?? '') : ''; if ($code !== 100 || !$authority) { $err = is_array($json) ? ($json['errors'] ?? null) : null; self::log('error', 'payment_gateway_request_failed', ['gateway' => 'zarinpal', 'order_id' => $orderId, 'amount_rial' => $amountRial, 'http' => $http, 'code' => $code, 'errors' => $err]); $msg = is_array($err) ? (string)($err['message'] ?? '') : ''; if ($msg && str_contains($msg, 'banned')) $msg = 'دامنه/ترمینال زرین‌پال اجازه تراکنش ندارد'; if ($trace && function_exists('set_transient')) set_transient('yektabot_pay_err_' . $trace, array_filter(['message' => $msg ?: null, 'code' => is_array($err) ? ($err['code'] ?? null) : null, 'http' => $http], fn($v) => $v !== null && $v !== ''), 90); return null; } return 'https://www.zarinpal.com/pg/StartPay/' . rawurlencode($authority); } private static function zarinpalVerify(string $merchantId, string $authority, int $amountRial): array { if (!function_exists('wp_remote_post') || !function_exists('wp_json_encode') || !function_exists('wp_remote_retrieve_body')) return ['ok' => false]; $body = wp_json_encode(['merchant_id' => $merchantId, 'authority' => $authority, 'amount' => $amountRial]); if (!$body) return ['ok' => false]; $res = wp_remote_post('https://api.zarinpal.com/pg/v4/payment/verify.json', [ 'timeout' => 15, 'redirection' => 0, 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 'body' => $body, ]); if (is_wp_error($res)) { self::log('error', 'payment_gateway_verify_failed', ['gateway' => 'zarinpal', 'authority' => $authority, 'amount_rial' => $amountRial, 'error' => $res->get_error_message()]); return ['ok' => false, 'error' => $res->get_error_message()]; } $http = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($res) : null; $json = json_decode((string)wp_remote_retrieve_body($res), true); $code = is_array($json) ? (int)($json['data']['code'] ?? 0) : 0; $ref = is_array($json) ? (string)($json['data']['ref_id'] ?? '') : ''; if (!in_array($code, [100, 101], true)) self::log('error', 'payment_gateway_verify_failed', ['gateway' => 'zarinpal', 'authority' => $authority, 'amount_rial' => $amountRial, 'http' => $http, 'code' => $code, 'raw' => $json]); return ['ok' => in_array($code, [100, 101], true), 'code' => $code, 'ref' => $ref, 'raw' => $json]; } private static function zibalRequest(string $merchant, int $amountRial, string $callbackUrl, string $trace = ''): ?string { if (!function_exists('wp_remote_post') || !function_exists('wp_json_encode') || !function_exists('wp_remote_retrieve_body')) return null; $body = wp_json_encode(['merchant' => $merchant, 'callbackUrl' => $callbackUrl, 'amount' => $amountRial]); if (!$body) return null; $res = wp_remote_post('https://gateway.zibal.ir/v1/request', [ 'timeout' => 15, 'redirection' => 0, 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 'body' => $body, ]); if (is_wp_error($res)) { self::log('error', 'payment_gateway_request_failed', ['gateway' => 'zibal', 'amount_rial' => $amountRial, 'error' => $res->get_error_message()]); if ($trace && function_exists('set_transient')) set_transient('yektabot_pay_err_' . $trace, ['message' => $res->get_error_message()], 90); return null; } $http = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($res) : null; $json = json_decode((string)wp_remote_retrieve_body($res)); $result = isset($json->result) ? (int)$json->result : 0; $trackId = isset($json->trackId) ? (string)$json->trackId : ''; if ($result !== 100 || !$trackId) { self::log('error', 'payment_gateway_request_failed', ['gateway' => 'zibal', 'amount_rial' => $amountRial, 'http' => $http, 'result' => $result, 'message' => isset($json->message) ? (string)$json->message : null]); $msg = isset($json->message) ? trim((string)$json->message) : ''; if ($trace && function_exists('set_transient')) set_transient('yektabot_pay_err_' . $trace, array_filter(['message' => $msg ?: null, 'result' => $result, 'http' => $http], fn($v) => $v !== null && $v !== ''), 90); return null; } return 'https://gateway.zibal.ir/start/' . rawurlencode($trackId); } private static function zibalVerify(string $merchant, string $trackId): array { if (!function_exists('wp_remote_post') || !function_exists('wp_json_encode') || !function_exists('wp_remote_retrieve_body')) return ['ok' => false]; $body = wp_json_encode(['merchant' => $merchant, 'trackId' => $trackId]); if (!$body) return ['ok' => false]; $res = wp_remote_post('https://gateway.zibal.ir/v1/verify', [ 'timeout' => 15, 'redirection' => 0, 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], 'body' => $body, ]); if (is_wp_error($res)) { self::log('error', 'payment_gateway_verify_failed', ['gateway' => 'zibal', 'trackId' => $trackId, 'error' => $res->get_error_message()]); return ['ok' => false, 'error' => $res->get_error_message()]; } $http = function_exists('wp_remote_retrieve_response_code') ? (int)wp_remote_retrieve_response_code($res) : null; $json = json_decode((string)wp_remote_retrieve_body($res), true); $result = is_array($json) ? (int)($json['result'] ?? 0) : 0; if ($result !== 100) self::log('error', 'payment_gateway_verify_failed', ['gateway' => 'zibal', 'trackId' => $trackId, 'http' => $http, 'result' => $result, 'raw' => $json]); return ['ok' => $result === 100, 'result' => $result, 'raw' => $json]; } private static function decodeSignedToken(string $token): array { $parts = explode('.', $token, 2); if (count($parts) !== 2) return [null, null, null]; $payloadJson = self::b64urlDecode($parts[0]); $sig = self::b64urlDecode($parts[1]); $payload = $payloadJson ? json_decode($payloadJson, true) : null; if (!is_array($payload) || !$payloadJson || !$sig) return [null, null, null]; return [$payload, $payloadJson, $sig]; } private static function b64urlDecode(string $value): string { $v = trim($value); if ($v === '') return ''; $v = strtr($v, '-_', '+/'); $pad = strlen($v) % 4; if ($pad) $v .= str_repeat('=', 4 - $pad); $decoded = base64_decode($v, true); return $decoded === false ? '' : $decoded; } private static function platformLabel(string $platform): string { $labels = ['telegram' => 'تلگرام', 'bale' => 'بله', 'instagram' => 'اینستاگرام']; return $labels[strtolower(trim($platform))] ?? 'اکانت'; } private static function platformProfileLink(?string $platform, ?string $username): string { $username = trim((string)$username); if ($username === '') return ''; $user = ltrim($username, '@'); $p = strtolower(trim((string)$platform)); if ($p === 'instagram') { return 'بازگشت به اینستاگرام'; } if ($p === 'telegram') { return 'بازگشت به تلگرام'; } if ($p === 'bale') { return 'بازگشت به بله'; } return ''; } private static function renderPayResult(bool $ok, string $title, string $message, ?int $orderId = null, ?int $amount = null, ?string $accountUsername = null, ?string $platform = null): void { self::$renderingPayment = true; $orderLine = $orderId ? ('
شماره سفارش' . esc_html((string)$orderId) . '
') : ''; $amountLine = $amount ? ('
مبلغ' . esc_html(number_format($amount)) . ' تومان
') : ''; $accountLine = self::platformProfileLink($platform, $accountUsername); self::enqueuePaymentAssets(); echo '' . esc_html($title) . ''; // Print this standalone page's registered stylesheet without loading theme scripts on the signed payment URL. wp_print_styles(['yektabot-payment']); echo ''; echo '

' . esc_html($title) . '

' . esc_html($message) . '

' . wp_kses_post($orderLine) . wp_kses_post($amountLine) . '
'; echo ''; self::$renderingPayment = false; } public static function maybeSync(): void { if (!is_admin() || !current_user_can('manage_options')) return; if (get_option(self::OPTION . '_synced') === (string)self::signature()) return; if (!self::ready(true)) return; self::sync(true); } public static function activate(): void { $error = self::preflightError(); if ($error !== null) { if (!function_exists('deactivate_plugins')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } deactivate_plugins(plugin_basename(__FILE__), true); wp_die( '

Yektabot به‌دلیل خطای سازگاری فعال نشد:

' . esc_html($error) . '

' . '

پیشخوان وردپرس بدون این پلاگین در دسترس است. پس از نصب نسخهٔ سالم دوباره فعال‌سازی کنید.

', 'خطای فعال‌سازی Yektabot', ['response' => 500, 'back_link' => true] ); } self::sync(); } public static function deactivate(): void { self::cleanup(); } public static function cleanup(): void { self::loadWoo(); $opts = self::opts(); $url = $opts['url'] ? self::target($opts) : ''; $table = self::webhookTable(); if ($table) { global $wpdb; $like = $wpdb->esc_like('Yektabot ') . '%'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Connection cleanup requires immediate writes to the trusted prefix/literal WooCommerce tables; all filter values are prepared and no plugin object cache is used. if ($url) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE name LIKE %s AND delivery_url = %s", $like, $url)); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Connection cleanup requires immediate writes to the trusted prefix/literal WooCommerce tables; all filter values are prepared and no plugin object cache is used. else $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE name LIKE %s", $like)); } if (class_exists('\WC_Webhooks') && method_exists('\WC_Webhooks', 'get_webhooks')) { foreach (self::webhooks() as $hook) { if ($hook->get_name() === 'Yektabot ' . $hook->get_topic() && ($url ? $hook->get_delivery_url() === $url : true)) $hook->delete(true); } } else { $ids = get_posts(['post_type' => 'shop_webhook', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids']); foreach ($ids as $id) { $title = (string)get_the_title((int)$id); if (!str_starts_with($title, 'Yektabot ')) continue; if ($url) { $delivery = (string)(get_post_meta((int)$id, '_webhook_delivery_url', true) ?: get_post_meta((int)$id, 'webhook_delivery_url', true)); if ($delivery !== $url) continue; } wp_delete_post((int)$id, true); } } if (!empty($opts['api_key_id']) && is_numeric($opts['api_key_id'])) { global $wpdb; if (isset($wpdb) && property_exists($wpdb, 'prefix')) { $keysTable = $wpdb->prefix . 'woocommerce_api_keys'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Connection cleanup requires immediate writes to the trusted prefix/literal WooCommerce tables; all filter values are prepared and no plugin object cache is used. $wpdb->query($wpdb->prepare("DELETE FROM {$keysTable} WHERE key_id = %d", (int)$opts['api_key_id'])); } } delete_option(self::OPTION . '_synced'); self::log('info', 'cleanup', ['url' => $url]); } /** * Which of the 3 Yektabot product-admin contexts the current admin screen is, or '' if none. * Shared by the asset enqueue, the shared modal injection and (indirectly) column/switch rendering. */ private static function currentProductAdminScreen(): string { if (!function_exists('get_current_screen')) return ''; $screen = get_current_screen(); if (!$screen) return ''; $postType = (string)($screen->post_type ?? ''); $base = (string)($screen->base ?? ''); $id = (string)($screen->id ?? ''); if ($postType === 'product' && $base === 'post') return 'edit'; if ($postType === 'product' && $base === 'edit') return 'list'; if ($id === 'toplevel_page_' . self::PAGE) return 'plugin'; return ''; } public static function enqueueProductAdminAssets($hook): void { $screen = self::currentProductAdminScreen(); if ($screen === '' || !current_user_can('edit_products')) return; if (function_exists('wp_enqueue_media')) wp_enqueue_media(); wp_enqueue_script('yektabot-product-admin', plugins_url('assets/product-admin.js', __FILE__), [], self::VERSION, true); wp_add_inline_script('yektabot-product-admin', self::productAdminJsConfig(), 'before'); wp_enqueue_style('yektabot-product-admin', plugins_url('assets/product-admin.css', __FILE__), [], self::VERSION); if ($screen === 'list') { wp_enqueue_style('yektabot-product-list', plugins_url('assets/product-list.css', __FILE__), ['yektabot-product-admin'], self::VERSION); } } /** Echoes the (hidden) connect + manage modal shells once, on the 3 Yektabot product-admin screens. */ public static function renderSharedProductModals(): void { if (self::currentProductAdminScreen() === '' || !current_user_can('edit_products')) return; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The modal method returns only a hardcoded HTML literal, with no dynamic values. echo self::connectModalMarkup(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The modal method returns only a hardcoded HTML literal, with no dynamic values. echo self::manageModalMarkup(); } private static function productAdminJsConfig(): string { $cfg = [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('yb_product_connect'), ]; return 'window.YektabotProductAdminCfg = ' . (wp_json_encode($cfg, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?: '{}') . ';'; } /** Switch inside the product edit submitbox, above Publish/Update. Replaces the old side meta box. */ public static function renderProductSubmitboxToggle(): void { global $post; if (!is_object($post) || (($post->post_type ?? '') !== 'product')) return; $productId = (int)($post->ID ?? 0); if ($productId <= 0 || !current_user_can('edit_product', $productId)) return; $opts = self::opts(); $storeConnected = !empty($opts['url']) && !empty($opts['secret']) && !empty($opts['account_id']); $connected = self::isProductConnected($productId); $pending = self::hasPendingConnect($productId); $on = $connected || $pending; echo '
'; if (!$storeConnected) { echo 'یکتابات: ابتدا فروشگاه را متصل کنید'; } else { echo ''; echo '

با فعال‌سازی این گزینه، اطلاعات محصول با یکتابات همگام می‌شود و مشتریان با ارسال کد یکتا می‌توانند محصول را مشاهده و سفارش دهند.

'; } echo '
'; } /** Insert a «یکتابات» status column into the WooCommerce products list table. */ public static function addProductListColumn(array $columns): array { // Place near the end (before date) so fixed-layout tables don't squeeze it between price/categories. $label = 'یکتابات'; $new = []; $inserted = false; foreach ($columns as $key => $value) { if ($key === 'date') { $new['yektabot_status'] = $label; $inserted = true; } $new[$key] = $value; } if (!$inserted) { $new['yektabot_status'] = $label; } return $new; } public static function renderProductListColumn($column, $postId): void { if ($column !== 'yektabot_status') return; $postId = (int)$postId; $connected = self::isProductConnected($postId); $pending = self::hasPendingConnect($postId); $label = $connected ? 'متصل' : ($pending ? 'در انتظار انتشار' : 'قطع'); $class = $connected ? 'yb-col-ok' : ($pending ? 'yb-col-pending' : 'yb-col-off'); $action = ($connected || $pending) ? 'manage' : 'connect'; $product = function_exists('wc_get_product') ? wc_get_product($postId) : null; $name = $product && method_exists($product, 'get_name') ? (string)$product->get_name() : (string)get_the_title($postId); printf( '
', esc_attr($class), esc_attr($action), $postId, esc_attr($name), $connected ? '1' : '0', $pending ? '1' : '0', esc_html($label) ); } /** All top-level (non-variation) products for the settings-page bulk picker. */ private static function productPickerList(): array { if (!function_exists('get_posts')) return []; $ids = get_posts([ 'post_type' => 'product', 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], 'posts_per_page' => 500, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', ]); $list = []; foreach ((array)$ids as $id) { $id = (int)$id; $product = function_exists('wc_get_product') ? wc_get_product($id) : null; if (!$product) continue; $list[] = [ 'id' => $id, 'name' => method_exists($product, 'get_name') ? (string)$product->get_name() : (string)get_the_title($id), 'type' => method_exists($product, 'get_type') ? (string)$product->get_type() : 'simple', 'connected' => self::isProductConnected($id), 'pending' => self::hasPendingConnect($id), ]; } return $list; } /** «اتصال محصولات به یکتابات» card + bulk picker modal, rendered on the settings page. */ private static function renderProductConnectCard(): void { $products = self::productPickerList(); $available = array_values(array_filter($products, fn($p) => !$p['connected'] && !$p['pending'])); echo '
محصولات
اتصال محصولات ووکامرس به یکتابات
'; echo '
' . (int)count($products) . ' محصول — ' . (int)count($available) . ' مورد قطع
'; echo '
'; echo '
همچنین می‌توانید از ستون «یکتابات» در فهرست محصولات یا از صفحه ویرایش هر محصول، آن را جداگانه متصل کنید.
'; echo '
'; echo ''; } private static function connectModalMarkup(): string { return <<<'HTML' HTML; } private static function manageModalMarkup(): string { return <<<'HTML' HTML; } /** * Shared admin JS for the plugin page, the product list column and the product edit submitbox switch. * One file powers: the connect/settings modal (single + bulk), the manage modal (disconnect/reconnect), * the bulk picker on the settings page, serial queued connect for multi-select, and live refresh of * the submitbox switch + list column badges after any change. */ /** * Build the Yektabot connect payload from the submitted modal fields, filling defaults from * the live WC product for anything not posted. Variation names use attribute labels only * (see variationAttributeLabel()), never WC_Product_Variation::get_name()'s "Parent — Attr" format. */ private static function collectConnectPayloadFromRequest(int $productId, $product): array { $type = sanitize_text_field(self::productRequestValue('type', method_exists($product, 'get_type') ? (string)$product->get_type() : 'simple')); $name = sanitize_text_field(self::productRequestValue('name', method_exists($product, 'get_name') ? (string)$product->get_name() : '')); // Unique code is independent of WooCommerce SKU — never write to product SKU. $uniqueCode = sanitize_text_field(self::productRequestValue('unique_code', self::productRequestValue('sku'))); if ($uniqueCode === '') { $uniqueCode = self::resolveUniqueCodeForForm($productId, $product); } if ($uniqueCode === '') { $uniqueCode = (string)$productId; } $description = wp_kses_post(self::productRequestValue('description', method_exists($product, 'get_short_description') ? (string)$product->get_short_description() : '')); $imageUrl = esc_url_raw(self::productRequestValue('image_url')); if ($imageUrl === '' && method_exists($product, 'get_image_id')) { $featuredId = (int)$product->get_image_id(); $imageUrl = $featuredId > 0 ? (string)wp_get_attachment_url($featuredId) : ''; } $flowId = absint(self::productRequestValue('flow_id', (string)get_post_meta($productId, self::META_FLOW_ID, true))); $payload = [ 'account_id' => (int)(self::opts()['account_id'] ?? 0), 'site' => home_url('/'), 'wc_product_id' => $productId, 'type' => $type === 'variable' ? 'variable' : 'simple', 'name' => $name, 'unique_code' => $uniqueCode, 'sku' => $uniqueCode, 'description' => $description, 'image_url' => $imageUrl, 'flow_id' => $flowId > 0 ? $flowId : null, 'price' => method_exists($product, 'get_price') ? $product->get_price() : '', 'regular_price' => method_exists($product, 'get_regular_price') ? $product->get_regular_price() : '', 'sale_price' => method_exists($product, 'get_sale_price') ? $product->get_sale_price() : '', 'manage_stock' => method_exists($product, 'get_manage_stock') ? $product->get_manage_stock() : false, 'stock_quantity' => method_exists($product, 'get_stock_quantity') ? $product->get_stock_quantity() : null, 'stock_status' => method_exists($product, 'get_stock_status') ? $product->get_stock_status() : '', ]; if ($payload['type'] === 'variable' && method_exists($product, 'get_children')) { $rawVariations = self::productRequestValue('variations', '[]'); if (is_string($rawVariations)) { $decoded = json_decode($rawVariations, true); $rawVariations = is_array($decoded) ? $decoded : []; } if (!is_array($rawVariations)) $rawVariations = []; $parentImageUrl = $imageUrl; $variations = []; foreach ($product->get_children() as $variationId) { $variation = function_exists('wc_get_product') ? wc_get_product((int)$variationId) : null; if (!$variation) continue; $override = null; foreach ($rawVariations as $row) { if (!is_array($row)) continue; if ((int)($row['id'] ?? 0) === (int)$variationId) { $override = $row; break; } } $img = ''; if (is_array($override) && !empty($override['image_url'])) { $img = esc_url_raw(is_string($override['image_url']) ? $override['image_url'] : ''); } else { $vImageId = method_exists($variation, 'get_image_id') ? (int)$variation->get_image_id() : 0; $img = $vImageId > 0 ? (string)wp_get_attachment_url($vImageId) : ''; } if ($img === '') { $img = $parentImageUrl; } $defaultName = self::variationAttributeLabel($variation); $overrideName = is_array($override) && isset($override['name']) && is_string($override['name']) ? trim($override['name']) : ''; $variations[] = [ 'id' => (int)$variationId, 'name' => $overrideName !== '' ? sanitize_text_field($overrideName) : $defaultName, 'sku' => method_exists($variation, 'get_sku') ? (string)$variation->get_sku() : '', 'image_url' => $img, 'price' => method_exists($variation, 'get_price') ? $variation->get_price() : '', 'regular_price' => method_exists($variation, 'get_regular_price') ? $variation->get_regular_price() : '', 'sale_price' => method_exists($variation, 'get_sale_price') ? $variation->get_sale_price() : '', 'manage_stock' => method_exists($variation, 'get_manage_stock') ? $variation->get_manage_stock() : false, 'stock_quantity' => method_exists($variation, 'get_stock_quantity') ? $variation->get_stock_quantity() : null, 'stock_status' => method_exists($variation, 'get_stock_status') ? $variation->get_stock_status() : '', ]; } $payload['variations'] = $variations; } return $payload; } /** * @return array{ok:bool,message?:string,flow_id?:int,unique_code?:string} */ private static function performRemoteConnect(int $productId, array $payload): array { $result = self::signedProductApi(self::PRODUCT_CONNECT_URL, $payload); if (!($result['ok'] ?? false)) { $detail = ''; if (is_array($result['data'] ?? null)) { $detail = trim((string)($result['data']['error'] ?? '')); } $message = $detail !== '' ? $detail : (string)($result['message'] ?? 'connect_failed'); return ['ok' => false, 'message' => $message, 'error' => $message]; } $newFlowId = (int)($result['data']['flow_id'] ?? 0); update_post_meta($productId, self::META_CONNECTED, '1'); if ($newFlowId > 0) { update_post_meta($productId, self::META_FLOW_ID, (string)$newFlowId); } $uniqueCode = (string)($payload['unique_code'] ?? ''); $remoteUnique = trim((string)($result['data']['unique_code'] ?? '')); if ($remoteUnique !== '') { self::persistUniqueCode($productId, $remoteUnique); $uniqueCode = $remoteUnique; } return [ 'ok' => true, 'flow_id' => $newFlowId > 0 ? $newFlowId : (int)get_post_meta($productId, self::META_FLOW_ID, true), 'unique_code' => $uniqueCode, ]; } /** * Connect (or settings-update) a single product. * - Published product → connects/updates remotely right away. * - Draft/pending/private/future product → defers: stores the payload as pending meta and * only actually connects once the product is published (see onProductPublish()). */ public static function ajaxProductConnect(): void { if (!current_user_can('edit_products')) { wp_send_json_error(['message' => self::translateProductAdminMessage('forbidden'), 'error' => 'forbidden'], 403); } check_ajax_referer('yb_product_connect'); $productId = absint(self::productRequestValue('product_id')); if ($productId <= 0 || !current_user_can('edit_product', $productId) || !function_exists('wc_get_product')) { wp_send_json_error(['message' => self::translateProductAdminMessage('invalid_product'), 'error' => 'invalid_product'], 422); } $product = wc_get_product($productId); if (!$product) { wp_send_json_error(['message' => self::translateProductAdminMessage('invalid_product'), 'error' => 'invalid_product'], 422); } $payload = self::collectConnectPayloadFromRequest($productId, $product); self::persistUniqueCode($productId, (string)$payload['unique_code']); $structureHash = self::currentStructureHash($productId, $product); $post = get_post($productId); $postStatus = $post ? (string)$post->post_status : ''; if ($postStatus !== 'publish') { self::storePendingConnect($productId, $payload); update_post_meta($productId, self::META_CONNECTED, '0'); self::storeStructureHash($productId, $structureHash); wp_send_json_success([ 'connected' => false, 'deferred' => true, 'pending' => true, 'flow_id' => (int)get_post_meta($productId, self::META_FLOW_ID, true), 'unique_code' => (string)$payload['unique_code'], ]); } $result = self::performRemoteConnect($productId, $payload); if (!$result['ok']) { $code = (string)($result['error'] ?? $result['message'] ?? 'connect_failed'); wp_send_json_error([ 'message' => self::translateProductAdminMessage($code), 'error' => $code, ], 422); } self::clearPendingConnect($productId); self::storeStructureHash($productId, $structureHash); wp_send_json_success([ 'flow_id' => $result['flow_id'], 'connected' => true, 'deferred' => false, 'pending' => false, 'unique_code' => $result['unique_code'], ]); } public static function ajaxProductDisconnect(): void { if (!current_user_can('edit_products')) { wp_send_json_error(['message' => self::translateProductAdminMessage('forbidden'), 'error' => 'forbidden'], 403); } check_ajax_referer('yb_product_connect'); $productId = absint(self::productRequestValue('product_id')); if ($productId <= 0 || !current_user_can('edit_product', $productId)) { wp_send_json_error(['message' => self::translateProductAdminMessage('invalid_product'), 'error' => 'invalid_product'], 422); } $wasConnected = self::isProductConnected($productId); $ok = true; if ($wasConnected) { $ok = self::remoteDisconnectProduct($productId); } self::unlinkProductLocally($productId, true); self::clearPendingConnect($productId); delete_post_meta($productId, self::META_STRUCTURE_HASH); if ($wasConnected && !$ok) { wp_send_json_error([ 'message' => self::translateProductAdminMessage('disconnect_failed'), 'error' => 'disconnect_failed', ], 422); } wp_send_json_success(['disconnected' => true]); } public static function ajaxProductStatus(): void { if (!current_user_can('edit_products')) { wp_send_json_error(['message' => 'forbidden'], 403); } check_ajax_referer('yb_product_connect'); $productId = absint(self::productRequestValue('product_id')); if ($productId <= 0 || !current_user_can('edit_product', $productId)) { wp_send_json_error(['message' => 'invalid_product'], 422); } $localConnected = self::isProductConnected($productId); $pending = self::hasPendingConnect($productId); $flowId = (int)get_post_meta($productId, self::META_FLOW_ID, true); $result = self::signedProductApi(self::PRODUCT_STATUS_URL, [ 'account_id' => (int)(self::opts()['account_id'] ?? 0), 'site' => home_url('/'), 'wc_product_id' => $productId, ]); if ($result['ok'] && !empty($result['data']['connected'])) { $remoteFlow = (int)($result['data']['flow_id'] ?? 0); update_post_meta($productId, self::META_CONNECTED, '1'); if ($remoteFlow > 0) { update_post_meta($productId, self::META_FLOW_ID, (string)$remoteFlow); $flowId = $remoteFlow; } $remoteUnique = trim((string)($result['data']['unique_code'] ?? '')); if ($remoteUnique !== '') { self::persistUniqueCode($productId, $remoteUnique); } wp_send_json_success([ 'connected' => true, 'pending' => false, 'flow_id' => $flowId, 'unique_code' => self::storedUniqueCode($productId) ?: $remoteUnique, ]); } if ($localConnected && empty($result['data']['connected']) && $result['ok']) { // Remote says disconnected — keep flow_id for reconnect but clear connected flag. update_post_meta($productId, self::META_CONNECTED, '0'); wp_send_json_success([ 'connected' => false, 'pending' => $pending, 'flow_id' => $flowId, 'unique_code' => self::storedUniqueCode($productId), ]); } wp_send_json_success([ 'connected' => $localConnected, 'pending' => $pending, 'flow_id' => $flowId, 'unique_code' => self::resolveUniqueCodeForForm($productId, function_exists('wc_get_product') ? wc_get_product($productId) : null), ]); } /** Returns productConnectDefaults() + connect/pending status for the connect & manage modals (list/plugin page). */ public static function ajaxProductDefaults(): void { if (!current_user_can('edit_products')) { wp_send_json_error(['message' => 'forbidden'], 403); } check_ajax_referer('yb_product_connect'); $productId = absint(self::productRequestValue('product_id')); if ($productId <= 0 || !current_user_can('edit_product', $productId) || !function_exists('wc_get_product')) { wp_send_json_error(['message' => 'invalid_product'], 422); } $product = wc_get_product($productId); if (!$product) { wp_send_json_error(['message' => 'invalid_product'], 422); } $defaults = self::productConnectDefaults($productId, $product); $post = get_post($productId); wp_send_json_success(array_merge($defaults, [ 'id' => $productId, 'type' => method_exists($product, 'get_type') ? (string)$product->get_type() : 'simple', 'post_status' => $post ? (string)$post->post_status : '', 'connected' => self::isProductConnected($productId), 'pending' => self::hasPendingConnect($productId), 'flow_id' => (int)get_post_meta($productId, self::META_FLOW_ID, true), ])); } /** * When a pending-connect draft/private/future product transitions to publish, apply the * previously saved connect settings (actual remote connect happens only now). */ public static function onProductPublish($newStatus, $oldStatus, $post): void { try { if ($newStatus !== 'publish' || $oldStatus === 'publish') return; if (!is_object($post) || (($post->post_type ?? '') !== 'product')) return; $productId = (int)($post->ID ?? 0); if ($productId <= 0 || !self::hasPendingConnect($productId)) return; $payload = self::storedPendingPayload($productId); if (empty($payload)) { self::clearPendingConnect($productId); return; } $result = self::performRemoteConnect($productId, $payload); if ($result['ok']) { self::clearPendingConnect($productId); $product = function_exists('wc_get_product') ? wc_get_product($productId) : null; self::storeStructureHash($productId, self::currentStructureHash($productId, $product)); self::log('info', 'product_pending_connect_applied', ['id' => $productId]); } else { self::log('warn', 'product_pending_connect_failed', ['id' => $productId, 'message' => $result['message'] ?? '']); } } catch (\Throwable $e) { self::log('error', 'product_pending_connect_failed', ['id' => (int)($post->ID ?? 0), 'error' => $e->getMessage()]); } } /** Authenticate the SaaS push using its exact raw JSON body and the configured secret. */ public static function authorizeProductPush($request) { $opts = self::opts(); if (!self::hasConnection($opts)) { return new \WP_Error('not_connected', 'The store is not connected.', ['status' => 401]); } if (!is_object($request) || !method_exists($request, 'get_body') || !method_exists($request, 'get_header')) { return new \WP_Error('unauthorized', 'Invalid request.', ['status' => 401]); } $raw = (string)$request->get_body(); $signature = (string)$request->get_header('x-yektabot-signature'); $expected = base64_encode(hash_hmac('sha256', $raw, (string)$opts['secret'], true)); if ($raw === '' || $signature === '' || !hash_equals($expected, $signature)) { return new \WP_Error('unauthorized', 'Invalid signature.', ['status' => 401]); } return true; } /** Read a scalar form field after the product action's capability and nonce checks. */ private static function productRequestValue(string $key, string $default = ''): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Only called by nonce-verified product AJAX handlers. return isset($_POST[$key]) && is_string($_POST[$key]) ? wp_unslash($_POST[$key]) : $default; } public static function registerRestRoutes(): void { register_rest_route('yektabot/v1', '/product-push', [ 'methods' => 'POST', 'callback' => [self::class, 'restProductPush'], 'permission_callback' => [self::class, 'authorizeProductPush'], ]); } /** * Signed YB → WC price/stock push. Auth via shared webhook secret (X-Yektabot-Signature). */ public static function restProductPush($request) { try { $permission = self::authorizeProductPush($request); if (is_wp_error($permission)) { $errorData = $permission->get_error_data(); return new \WP_REST_Response(['message' => $permission->get_error_code()], (int)($errorData['status'] ?? 401)); } $opts = self::opts(); $raw = (string)$request->get_body(); $data = json_decode($raw, true); if (!is_array($data)) { return new \WP_REST_Response(['message' => 'invalid_json'], 400); } $connectedAccount = (int)($opts['account_id'] ?? 0); if ($connectedAccount > 0 && !empty($data['account_id']) && (int)$data['account_id'] !== $connectedAccount) { return new \WP_REST_Response(['message' => 'account_mismatch'], 403); } $productId = filter_var($data['wc_product_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); $variationId = filter_var($data['wc_variation_id'] ?? 0, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); if ($productId === false || $variationId === false) { return new \WP_REST_Response(['message' => 'invalid_product'], 422); } if (!self::isProductConnected($productId)) { return new \WP_REST_Response(['message' => 'not_connected'], 404); } self::loadWoo(); if (!function_exists('wc_get_product')) { return new \WP_REST_Response(['message' => 'woocommerce_missing'], 500); } $parent = wc_get_product($productId); if (!$parent || $parent->is_type('variation')) { return new \WP_REST_Response(['message' => 'invalid_product'], 422); } $product = $variationId > 0 ? wc_get_product($variationId) : $parent; if (!$product || ($variationId > 0 && (!$product->is_type('variation') || (int)$product->get_parent_id() !== $productId))) { return new \WP_REST_Response(['message' => 'invalid_variation'], 422); } $hasPrice = array_key_exists('regular_price', $data); $hasStock = array_key_exists('manage_stock', $data); $hasUnique = array_key_exists('unique_code', $data) || array_key_exists('yektabot_unique_code', $data); $price = ''; if ($hasPrice) { $rawPrice = $data['regular_price']; if ((!is_string($rawPrice) && !is_int($rawPrice) && !is_float($rawPrice)) || !is_numeric($rawPrice) || !is_finite((float)$rawPrice) || (float)$rawPrice < 0) { return new \WP_REST_Response(['message' => 'invalid_price'], 422); } $price = wc_format_decimal($rawPrice); } $manage = false; $qty = 0; if ($hasStock) { $manage = is_scalar($data['manage_stock']) ? filter_var($data['manage_stock'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) : null; $qty = filter_var($data['stock_quantity'] ?? 0, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); if ($manage === null || ($manage && $qty === false)) { return new \WP_REST_Response(['message' => 'invalid_stock'], 422); } } // Validate the complete signed update and product/variation relationship before any write. if ($hasUnique) { $rawUnique = $data['unique_code'] ?? $data['yektabot_unique_code'] ?? ''; if (!is_string($rawUnique) && !is_int($rawUnique)) { return new \WP_REST_Response(['message' => 'invalid_unique_code'], 422); } self::persistUniqueCode($productId, sanitize_text_field((string)$rawUnique)); } if (!$hasPrice && !$hasStock) { return new \WP_REST_Response([ 'ok' => true, 'wc_product_id' => $productId, 'unique_code' => self::storedUniqueCode($productId), ], 200); } self::$suppressOutbound = true; try { if ($hasPrice) { if (method_exists($product, 'set_regular_price')) $product->set_regular_price($price); if (method_exists($product, 'set_sale_price')) $product->set_sale_price(''); if (method_exists($product, 'set_price')) $product->set_price($price); } if ($hasStock) { if (method_exists($product, 'set_manage_stock')) $product->set_manage_stock($manage); if ($manage) { if (method_exists($product, 'set_stock_quantity')) $product->set_stock_quantity($qty); if (method_exists($product, 'set_stock_status')) { $product->set_stock_status($qty > 0 ? 'instock' : 'outofstock'); } } elseif (method_exists($product, 'set_stock_status')) { $product->set_stock_status('instock'); } } $product->save(); if (function_exists('wc_delete_product_transients')) { wc_delete_product_transients($productId); if ($variationId > 0) wc_delete_product_transients($variationId); } self::log('info', 'product_push_applied', [ 'wc_product_id' => $productId, 'wc_variation_id' => $variationId > 0 ? $variationId : null, 'has_price' => $hasPrice, 'has_stock' => $hasStock, 'has_unique_code' => $hasUnique, ]); } finally { self::$suppressOutbound = false; } return new \WP_REST_Response([ 'ok' => true, 'wc_product_id' => $productId, 'wc_variation_id' => $variationId > 0 ? $variationId : null, 'unique_code' => self::storedUniqueCode($productId), ], 200); } catch (\Throwable $e) { self::$suppressOutbound = false; self::log('error', 'product_push_failed', ['error' => $e->getMessage()]); return new \WP_REST_Response(['message' => 'error'], 500); } } private static function productConnectDefaults(int $productId, $product): array { if (!$product && function_exists('wc_get_product')) { $product = wc_get_product($productId); } if (!$product) { return ['name' => '', 'sku' => '', 'description' => '', 'image_url' => '', 'variations' => []]; } $imageId = $product->get_image_id(); $imageUrl = $imageId ? (string)wp_get_attachment_url($imageId) : ''; $data = [ 'name' => (string)$product->get_name(), 'sku' => self::resolveUniqueCodeForForm($productId, $product), 'description' => (string)$product->get_short_description(), 'image_url' => $imageUrl, 'variations' => [], ]; if ($product->get_type() === 'variable') { foreach ($product->get_children() as $variationId) { $variation = wc_get_product((int)$variationId); if (!$variation) continue; $vImageId = $variation->get_image_id(); $data['variations'][] = [ 'id' => (int)$variationId, 'name' => self::variationAttributeLabel($variation), 'sku' => (string)$variation->get_sku(), 'image_url' => $vImageId ? (string)wp_get_attachment_url($vImageId) : $imageUrl, ]; } } return $data; } /** * Human-readable variation label built from attribute values only (e.g. "آبی - بزرگ"), * NOT WC_Product_Variation::get_name()'s "Parent Name - Attr1, Attr2" format. */ private static function variationAttributeLabel($variation): string { if (!is_object($variation)) return ''; $parts = []; if (method_exists($variation, 'get_attributes')) { foreach ((array)$variation->get_attributes() as $key => $value) { if ($value === '' || $value === null) continue; $label = (string)$value; $taxonomy = is_string($key) ? $key : ''; if ($taxonomy !== '' && str_starts_with($taxonomy, 'pa_') && function_exists('get_term_by')) { $term = get_term_by('slug', $label, $taxonomy); if ($term && !is_wp_error($term) && !empty($term->name)) { $label = (string)$term->name; } } $parts[] = $label; } } if (!empty($parts)) { return implode(' - ', $parts); } // Fallback only when no attributes are set on the variation at all. return method_exists($variation, 'get_name') ? (string)$variation->get_name() : ''; } private static function hasPendingConnect(int $productId): bool { return (string)get_post_meta($productId, self::META_PENDING_CONNECT, true) === '1'; } private static function storedPendingPayload(int $productId): array { $raw = (string)get_post_meta($productId, self::META_CONNECT_PAYLOAD, true); if ($raw === '') return []; $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } private static function storePendingConnect(int $productId, array $payload): void { update_post_meta($productId, self::META_PENDING_CONNECT, '1'); // WordPress strips slashes when storing meta; protect the JSON's escaped quotes and backslashes. update_post_meta($productId, self::META_CONNECT_PAYLOAD, wp_slash(wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')); } private static function clearPendingConnect(int $productId): void { delete_post_meta($productId, self::META_PENDING_CONNECT); delete_post_meta($productId, self::META_CONNECT_PAYLOAD); } private static function storedStructureHash(int $productId): string { return (string)get_post_meta($productId, self::META_STRUCTURE_HASH, true); } private static function storeStructureHash(int $productId, string $hash): void { if ($hash === '') return; update_post_meta($productId, self::META_STRUCTURE_HASH, $hash); } /** Fingerprint = product type + sorted variation IDs. Used to detect simple↔variable / variation add-remove. */ private static function currentStructureHash(int $productId, $product = null): string { if (!$product && function_exists('wc_get_product')) { $product = wc_get_product($productId); } if (!$product) return ''; $type = method_exists($product, 'get_type') ? (string)$product->get_type() : 'simple'; $children = []; if ($type === 'variable' && method_exists($product, 'get_children')) { $children = array_map('intval', (array)$product->get_children()); } sort($children); return md5($type . '|' . implode(',', $children)); } /** * If a product is connected (or has a pending connect with switch intent on) and its structure * (simple↔variable, or variation added/removed) changed since the last connect/pending-save, * force a disconnect (remote too, when actually connected) and clear the pending settings. * The user must reconnect with new settings afterwards. */ private static function maybeHandleStructureChange(int $productId): void { try { if ($productId <= 0) return; $connected = self::isProductConnected($productId); $pending = self::hasPendingConnect($productId); if (!$connected && !$pending) return; $storedHash = self::storedStructureHash($productId); if ($storedHash === '') return; $currentHash = self::currentStructureHash($productId); if ($currentHash === '' || $currentHash === $storedHash) return; if ($connected) { self::remoteDisconnectProduct($productId); } self::unlinkProductLocally($productId, true); self::clearPendingConnect($productId); delete_post_meta($productId, self::META_STRUCTURE_HASH); self::log('info', 'product_structure_changed_autodisconnect', ['id' => $productId]); } catch (\Throwable $e) { self::log('error', 'product_structure_change_check_failed', ['id' => $productId, 'error' => $e->getMessage()]); } } private static function storedUniqueCode(int $productId): string { return trim((string)get_post_meta($productId, self::META_UNIQUE_CODE, true)); } /** * Prefer saved plugin unique-code meta. Only seed from WC SKU when meta is empty (first connect). */ private static function resolveUniqueCodeForForm(int $productId, $product = null): string { $stored = self::storedUniqueCode($productId); if ($stored !== '') { return $stored; } if (!$product && function_exists('wc_get_product')) { $product = wc_get_product($productId); } if ($product && method_exists($product, 'get_sku')) { return trim((string)$product->get_sku()); } return ''; } private static function persistUniqueCode(int $productId, string $code): void { $code = sanitize_text_field($code); update_post_meta($productId, self::META_UNIQUE_CODE, $code); } private static function pushUniqueCodeToYektabot(int $productId, string $uniqueCode): void { try { $opts = self::opts(); if (empty($opts['url']) || empty($opts['secret']) || empty($opts['account_id'])) { return; } $result = self::signedProductApi(self::PRODUCT_UNIQUE_CODE_URL, [ 'account_id' => (int)$opts['account_id'], 'site' => home_url('/'), 'wc_product_id' => $productId, 'unique_code' => $uniqueCode, ]); self::log($result['ok'] ? 'info' : 'warn', 'unique_code_pushed', [ 'wc_product_id' => $productId, 'ok' => !empty($result['ok']), 'message' => $result['message'] ?? null, ]); } catch (\Throwable $e) { self::log('error', 'unique_code_push_failed', ['error' => $e->getMessage(), 'wc_product_id' => $productId]); } } private static function isProductConnected(int $productId): bool { return (string)get_post_meta($productId, self::META_CONNECTED, true) === '1'; } private static function productHasFlowMeta(int $productId): bool { return (int)get_post_meta($productId, self::META_FLOW_ID, true) > 0; } /** * Resolve a product or variation ID to the connected parent product ID (0 if not connected). */ private static function resolveConnectedParentId(int $productOrVariationId): int { $productOrVariationId = (int)$productOrVariationId; if ($productOrVariationId <= 0) return 0; if (self::isProductConnected($productOrVariationId) || self::productHasFlowMeta($productOrVariationId)) { return $productOrVariationId; } if (!function_exists('wc_get_product')) return 0; $product = wc_get_product($productOrVariationId); if (!$product) return 0; $parentId = (int)$product->get_parent_id(); if ($parentId > 0 && (self::isProductConnected($parentId) || self::productHasFlowMeta($parentId))) { return $parentId; } return 0; } private static function unlinkProductLocally(int $productId, bool $keepFlowId): void { update_post_meta($productId, self::META_CONNECTED, '0'); if (!$keepFlowId) { delete_post_meta($productId, self::META_FLOW_ID); } } private static function remoteDisconnectProduct(int $productId): bool { $opts = self::opts(); if (empty($opts['url']) || empty($opts['secret']) || empty($opts['account_id'])) { return false; } $result = self::signedProductApi(self::PRODUCT_DISCONNECT_URL, [ 'account_id' => (int)$opts['account_id'], 'site' => home_url('/'), 'wc_product_id' => $productId, ]); return !empty($result['ok']); } /** Map machine error codes from connect/disconnect APIs to Persian admin messages. */ private static function translateProductAdminMessage(string $code): string { $map = [ 'forbidden' => 'دسترسی مجاز نیست.', 'bad_nonce' => 'نشست منقضی شده است. صفحه را تازه کنید.', 'invalid_product' => 'محصول نامعتبر است.', 'unique_code_required' => 'کد یکتا الزامی است.', 'unique_code_taken' => 'این کد یکتا قبلاً استفاده شده است.', 'connect_failed' => 'اتصال به یکتابات ناموفق بود.', 'not_connected' => 'ابتدا فروشگاه را به یکتابات متصل کنید.', 'disconnect_failed' => 'قطع اتصال ناموفق بود.', 'request_failed' => 'ارتباط با سرور یکتابات برقرار نشد.', 'encode_failed' => 'ارسال اطلاعات ناموفق بود.', 'unauthorized' => 'دسترسی به یکتابات مجاز نیست.', 'account_not_found' => 'حساب یکتابات یافت نشد.', 'invalid_wc_product_id' => 'شناسه محصول نامعتبر است.', 'product_not_connected' => 'این محصول هنوز به یکتابات متصل نیست.', 'flow_missing' => 'محصول متناظر در یکتابات یافت نشد.', 'name_required' => 'نام محصول الزامی است.', 'variation_name_required' => 'نام تنوع محصول الزامی است.', 'description_too_long' => 'توضیحات محصول بیش از حد طولانی است.', ]; if (isset($map[$code])) { return $map[$code]; } if (preg_match('/[\x{0600}-\x{06FF}]/u', $code)) { return $code; } return 'خطایی رخ داد. لطفاً دوباره تلاش کنید.'; } /** * @return array{ok:bool,message?:string,data?:array} */ private static function signedProductApi(string $url, array $payload): array { $opts = self::opts(); if (!self::hasConnection($opts)) { return ['ok' => false, 'message' => 'not_connected']; } $body = wp_json_encode($payload); if (!$body) { return ['ok' => false, 'message' => 'encode_failed']; } $sig = base64_encode(hash_hmac('sha256', $body, (string)$opts['secret'], true)); $resp = wp_remote_post($url, [ 'timeout' => 30, 'redirection' => 0, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'X-Yektabot-Signature' => $sig, ], 'body' => $body, ]); if (is_wp_error($resp)) { return ['ok' => false, 'message' => $resp->get_error_message()]; } $code = (int)wp_remote_retrieve_response_code($resp); $raw = (string)wp_remote_retrieve_body($resp); $data = json_decode($raw, true); if ($code < 200 || $code >= 300 || !is_array($data)) { return ['ok' => false, 'message' => is_array($data) ? (string)($data['message'] ?? 'request_failed') : 'request_failed', 'data' => is_array($data) ? $data : []]; } return ['ok' => true, 'data' => $data]; } private static function redirectToService(string $url): void { $host = strtolower((string)wp_parse_url($url, PHP_URL_HOST)); if (wp_parse_url($url, PHP_URL_SCHEME) !== 'https' || !in_array($host, ['app.yektabot.com', 'www.zarinpal.com', 'gateway.zibal.ir'], true)) { wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE)); return; } $allowHost = static function (array $hosts) use ($host): array { $hosts[] = $host; return $hosts; }; add_filter('allowed_redirect_hosts', $allowHost); wp_safe_redirect($url); remove_filter('allowed_redirect_hosts', $allowHost); } public static function enqueueAdminAssets(string $hookSuffix): void { if (!current_user_can('manage_options')) return; wp_enqueue_style('yektabot-admin-menu', plugins_url('assets/admin-menu.css', __FILE__), [], self::VERSION); if ($hookSuffix !== 'toplevel_page_' . self::PAGE) return; wp_enqueue_style('yektabot-admin', plugins_url('assets/admin.css', __FILE__), [], self::VERSION); } private static function hasConnection(array $opts): bool { return self::sanitizeWebhookUrl($opts['url'] ?? '') !== '' && !empty($opts['secret']) && !empty($opts['account_id']); } private static function sendServiceSettings(string $event): void { if (!in_array($event, ['connected', 'disconnected', 'heartbeat'], true)) return; $opts = self::opts(); if (!self::hasConnection($opts)) return; $gateways = []; if (!empty($opts['payment_enabled'])) { if (!empty($opts['zarinpal_merchant_id'])) $gateways[] = 'zarinpal'; if (!empty($opts['zibal_merchant_id'])) $gateways[] = 'zibal'; } $payload = [ 'event' => $event, 'install_id' => self::installId(), 'plugin_version' => self::VERSION, 'account_id' => (int)$opts['account_id'], 'platform' => (string)$opts['platform'], 'account_username' => (string)$opts['account_username'], 'account_name' => (string)$opts['account_name'], 'ig_username' => (string)$opts['ig_username'], 'ig_name' => (string)$opts['ig_name'], 'site' => home_url('/'), 'payment_url' => home_url('/?yektabot_pay=1'), 'payment_gateway_default' => (string)$opts['payment_gateway_default'], 'payment_enabled' => !empty($opts['payment_enabled']), 'payment_gateways' => $gateways, ]; $json = wp_json_encode($payload); if (!$json) return; wp_remote_post(self::SERVICE_SETTINGS_URL, [ 'timeout' => 6, 'redirection' => 0, 'blocking' => false, 'headers' => [ 'Content-Type' => 'application/json', 'X-Yektabot-Signature' => base64_encode(hash_hmac('sha256', $json, (string)$opts['secret'], true)), ], 'body' => $json, ]); } public static function handleAdminActions(): void { if (!current_user_can('manage_options')) return; $page = isset($_GET['page']) && is_string($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; if ($page !== self::PAGE) return; $action = isset($_POST['yb_action']) && is_string($_POST['yb_action']) ? sanitize_key(wp_unslash($_POST['yb_action'])) : ''; if ($action === 'connect' && check_admin_referer('yb_connect')) self::startConnect(); if ($action === 'disconnect' && check_admin_referer('yb_disconnect')) self::disconnect(); $callback = isset($_GET['yb_action']) && is_string($_GET['yb_action']) ? sanitize_key(wp_unslash($_GET['yb_action'])) : ''; // The callback requires both a WordPress nonce and the expiring administrator-bound state. if ($callback === 'connected') self::handleConnectCallback(); } private static function sanitizeWebhookUrl($value): string { if (!is_string($value)) return ''; $url = esc_url_raw($value, ['https']); $parts = wp_parse_url($url); if (!is_array($parts) || strtolower((string)($parts['scheme'] ?? '')) !== 'https' || strtolower((string)($parts['host'] ?? '')) !== 'app.yektabot.com' || (string)($parts['path'] ?? '') !== '/woocommerce/webhook' || isset($parts['user']) || isset($parts['pass']) || (isset($parts['port']) && (int)$parts['port'] !== 443)) { return ''; } return 'https://app.yektabot.com/woocommerce/webhook'; } public static function enqueuePaymentAssets(): void { if (!self::$renderingPayment) return; wp_enqueue_style('yektabot-payment', plugins_url('assets/payment.css', __FILE__), [], self::VERSION); } } Plugin::boot();