plugin = $plugin; } public function hooks(): void { if ($this->plugin->is_main()) { // Keep the cookie current for classic WooCommerce and Store API // additions/removals without touching the cart contents. add_action('woocommerce_add_to_cart', [$this, 'publish_after_cart_change'], 999, 6); add_action('woocommerce_store_api_cart_item_added', [$this, 'publish_after_cart_change'], 999, 99); add_action('woocommerce_cart_item_removed', [$this, 'publish_after_cart_change'], 999, 2); add_action('woocommerce_after_cart_item_quantity_update', [$this, 'publish_after_cart_change'], 999, 4); add_action('woocommerce_cart_emptied', [$this, 'publish_after_cart_change'], 999); add_action('woocommerce_checkout_order_processed', [$this, 'publish_after_cart_change'], 999, 3); add_action('woocommerce_thankyou', [$this, 'publish_after_cart_change'], 999, 1); add_action('template_redirect', [$this, 'maybe_status_json'], 1); add_action('wp_enqueue_scripts', [$this, 'enqueue_main_script'], 20); add_filter('wp_redirect', [$this, 'append_count_to_source_return'], 20, 2); } if ($this->plugin->is_source()) { // The Main store adds this short-lived, signed value to the normal // post-transfer return URL. This also works when the stores use // mapped domains and cannot share a browser cookie. add_action('init', [$this, 'consume_return_count'], 1); add_filter('wp_redirect', [$this, 'remove_count_from_source_return'], 20, 2); add_action('wp_enqueue_scripts', [$this, 'enqueue_source_script'], 20); } } public function publish_after_cart_change(...$args): void { if (!$this->plugin->is_main()) { return; } $count = $this->current_cart_count(); if ($count !== null) { $this->set_count_cookie($count); } } public function maybe_status_json(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only cart-count endpoint; this query parameter does not mutate state. if (!$this->plugin->is_main() || empty($_GET[self::STATUS_QUERY])) { return; } $count = $this->current_cart_count(); if ($count === null) { $count = 0; } $this->set_count_cookie($count); nocache_headers(); wp_send_json_success(['count' => $count]); } public function consume_return_count(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The return count is authenticated below with an HMAC signature shared between configured stores. if (!$this->plugin->is_source() || !isset($_GET[self::COUNT_QUERY], $_GET[self::SIGNATURE_QUERY])) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- HMAC verification below authenticates this cross-store value. $count_raw = sanitize_text_field((string) wp_unslash($_GET[self::COUNT_QUERY])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- HMAC verification below authenticates this cross-store signature. $signature = sanitize_text_field((string) wp_unslash($_GET[self::SIGNATURE_QUERY])); if (!preg_match('/^\d{1,4}$/', $count_raw)) { return; } $count = min(self::MAX_COUNT, absint($count_raw)); $secret = $this->plugin->get_secret(); if ($secret === '' || !preg_match('/^[a-f0-9]{64}$/i', $signature)) { return; } if (!hash_equals(hash_hmac('sha256', (string) $count, $secret), strtolower($signature))) { return; } $this->set_count_cookie($count); } public function append_count_to_source_return($location, $status = 302) { if (!$this->plugin->is_main() || !is_string($location) || $location === '') { return $location; } $parts = wp_parse_url($location); if (!is_array($parts) || empty($parts['query'])) { return $location; } $query = []; parse_str((string) $parts['query'], $query); if ((string) ($query['3dl_cc_free_added'] ?? '') !== '1') { return $location; } $count = $this->current_cart_count(); if ($count === null) { return $location; } $signature = hash_hmac('sha256', (string) $count, $this->plugin->get_secret()); return add_query_arg([ self::COUNT_QUERY => $count, self::SIGNATURE_QUERY => $signature, ], $location); } public function remove_count_from_source_return($location, $status = 302) { if (!$this->plugin->is_source() || !is_string($location) || $location === '') { return $location; } if (strpos($location, self::COUNT_QUERY) === false && strpos($location, self::SIGNATURE_QUERY) === false) { return $location; } return remove_query_arg([self::COUNT_QUERY, self::SIGNATURE_QUERY], $location); } public function enqueue_main_script(): void { if (!$this->plugin->is_main() || is_admin() || wp_doing_ajax()) { return; } $count = $this->current_cart_count(); if ($count === null) { return; } $status_url = add_query_arg(self::STATUS_QUERY, '1', home_url('/')); $this->enqueue_script_config($count, $status_url, 'main'); } public function enqueue_source_script(): void { if (!$this->plugin->is_source() || is_admin() || wp_doing_ajax()) { return; } $main_url = $this->plugin->main_store_url(); if ($main_url === '') { return; } $destination_query = class_exists('ThreeDL_CC_Free_Transfer') ? ThreeDL_CC_Free_Transfer::DESTINATION_QUERY : '3dl_cc_free_destination'; $cart_url = add_query_arg($destination_query, 'cart', $main_url); $checkout_url = add_query_arg($destination_query, 'checkout', $main_url); $this->enqueue_script_config(0, '', 'source', $cart_url, $checkout_url); } private function enqueue_script_config(int $initial_count, string $status_url, string $role, string $cart_url = '', string $checkout_url = ''): void { $config = [ 'cookie' => self::COOKIE, 'cookie_ttl' => self::COOKIE_TTL, 'initial_count' => max(0, min(self::MAX_COUNT, $initial_count)), 'status_url' => $status_url, 'role' => $role, 'cart_url' => $cart_url, 'checkout_url' => $checkout_url, 'cookie_domain' => $this->cookie_domain(), ]; wp_enqueue_script( '3dl-cc-free-status', THREEDL_CC_FREE_URL . 'assets/js/status.js', [], THREEDL_CC_FREE_VERSION, true ); wp_add_inline_script( '3dl-cc-free-status', 'window.ThreeDLCCFreeStatus = ' . wp_json_encode($config) . ';', 'before' ); } private function current_cart_count(): ?int { if (!function_exists('WC') || !WC() || !WC()->cart || !is_a(WC()->cart, 'WC_Cart')) { return null; } return max(0, min(self::MAX_COUNT, (int) WC()->cart->get_cart_contents_count())); } private function set_count_cookie(int $count): void { $count = max(0, min(self::MAX_COUNT, $count)); $value = rawurlencode((string) $count . '|' . time()); $domain = $this->cookie_domain(); $secure = is_ssl(); if (!headers_sent()) { if (PHP_VERSION_ID >= 70300) { setcookie(self::COOKIE, $value, [ 'expires' => time() + self::COOKIE_TTL, 'path' => '/', 'domain' => $domain, 'secure' => $secure, 'httponly' => false, 'samesite' => 'Lax', ]); } else { setcookie(self::COOKIE, $value, time() + self::COOKIE_TTL, '/', $domain, $secure, false); } } $_COOKIE[self::COOKIE] = $value; } private function cookie_domain(): string { if (defined('COOKIE_DOMAIN') && is_string(COOKIE_DOMAIN) && COOKIE_DOMAIN !== '') { return COOKIE_DOMAIN; } if (!is_multisite() || !function_exists('get_network')) { return ''; } $network = get_network(); $network_domain = is_object($network) ? strtolower(trim((string) ($network->domain ?? ''))) : ''; $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); if ($network_domain === '' || $host === '' || filter_var($network_domain, FILTER_VALIDATE_IP)) { return ''; } if ($host !== $network_domain && substr($host, -strlen('.' . $network_domain)) !== '.' . $network_domain) { return ''; } if (strpos($network_domain, '.') === false) { return ''; } return '.' . ltrim($network_domain, '.'); } }