register(); } // ConversionOS — server-side conversion hooks. Each posts to // /v1/conversion-os/conversion with the site-key header. The // backend gates on subscription.conversion_os_enabled, so the // endpoint no-ops gracefully for non-add-on subscribers. // // Purchase fires from FOUR hooks sharing one per-order sentinel: // - woocommerce_thankyou: shopper returned to order-received — // fastest signal, but skipped while the order still needs // payment (pending/failed) so an unpaid order never counts. // - woocommerce_payment_complete: the gateway confirmed payment // (IPN/webhook) — fires even when the shopper never returns // from the gateway, so the sale isn't lost with the tab. // - order_status_processing / _completed: manual gateways (COD, // BACS) and admin-driven flows that never call // payment_complete. // Whichever runs first sends; the rest no-op on the sentinel. add_action('woocommerce_thankyou', array($this, 'on_woo_thankyou'), 10, 1); add_action('woocommerce_payment_complete', array($this, 'on_woo_order_paid'), 10, 1); add_action('woocommerce_order_status_processing', array($this, 'on_woo_order_paid'), 10, 1); add_action('woocommerce_order_status_completed', array($this, 'on_woo_order_paid'), 10, 1); // Stash the shopper's visitor/session ids on the order at checkout, // while their cookies are still on the request — the paid-status // hooks above run in IPN/admin context where those cookies are the // gateway's (i.e. absent), and attribution would otherwise be lost. add_action('woocommerce_checkout_order_created', array($this, 'capture_order_visitor_context'), 10, 1); add_action('woocommerce_store_api_checkout_order_processed', array($this, 'capture_order_visitor_context'), 10, 1); add_action('adprotektor_deliver_event', array($this, 'deliver_pending_event'), 10, 1); add_action('woocommerce_order_status_cancelled', array($this, 'on_woo_order_cancelled'), 10, 1); add_action('woocommerce_order_status_refunded', array($this, 'on_woo_order_cancelled'), 10, 1); add_action('gform_after_submission', array($this, 'on_gform_submission'), 10, 2); add_action('wpcf7_mail_sent', array($this, 'on_cf7_submission'), 10, 1); add_action('wpforms_process_complete', array($this, 'on_wpforms_submission'), 10, 4); add_action('elementor_pro/forms/new_record', array($this, 'on_elementor_submission'), 10, 2); // WooCommerce funnel events (Meta Standard Events). Server-side // hooks are adblock-immune and run before the CDN browser detector // races. Plugin events tagged source=plugin / confidence=high will // supersede any concurrent low-confidence CDN event for the same // visitor + event_name in the backend dedup window. add_action('woocommerce_add_to_cart', array($this, 'on_woo_add_to_cart'), 10, 6); add_action('woocommerce_before_checkout_form', array($this, 'on_woo_initiate_checkout'), 10, 1); add_action('template_redirect', array($this, 'on_woo_view_content'), 20); // Checkout protection. Before WooCommerce creates an order, ask // AdProtektor whether this shopper is one it has flagged on hard // evidence (a bot, an operator's block, outside the allowed area). A // flagged shopper is refused with a reference they can quote; everyone // else, and every failure to get an answer, goes through. This is the // server-side half: the tracking script already stops the flagged // shopper's add to cart and checkout in the browser, and this catches // whatever skips the browser (a bot posting the checkout directly). // Classic checkout — and the express buttons built on it — refuse at // validation; the Checkout block and Store API refuse while the order // is still a checkout draft, so it never becomes an order. add_action('woocommerce_after_checkout_validation', array($this, 'guard_classic_checkout'), 1, 2); add_action('woocommerce_store_api_checkout_update_order_from_request', array($this, 'guard_block_checkout'), 1, 2); } public function add_admin_menu() { add_options_page( 'AdProtektor Settings', 'AdProtektor', 'manage_options', 'adprotektor', array($this, 'settings_page') ); // Read-only diagnostics page. Configuration lives in the // AdProtektor dashboard, not here — this only displays status // + a rolling event log. add_submenu_page( 'options-general.php', 'AdProtektor Activity', 'AdProtektor Activity', 'manage_options', 'adprotektor-activity', array($this, 'activity_page') ); } public function register_settings() { register_setting('adprotektor_settings', 'adprotektor_tracking_code', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', )); // Site key — required for ConversionOS server-side hooks. The // backend's /v1/conversion-os/conversion endpoint authenticates // by hashing this against domains.settings.site_key_hash. register_setting('adprotektor_settings', 'adprotektor_site_key', array( 'type' => 'string', // Write-only field: it is rendered blank (see the settings form), so a // blank submission means "leave it as it is", not "clear it". Without // this, saving the settings page for any other reason wiped the key // and silently killed every server-side conversion. // // Retain-on-blank needs an explicit way OUT, or a revoked key could // never be removed through the UI — hence the "remove the saved key" // checkbox beside the field, which is the only thing that clears it. 'sanitize_callback' => function ($value) { // The checkbox counts only on this settings form's own submission. // options.php has checked its settings_fields() nonce already; // checking it here too means no other request that happens to // carry the field can clear the key. if (isset($_POST['_wpnonce'], $_POST['adprotektor_site_key_clear']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'adprotektor_settings-options') && sanitize_text_field(wp_unslash($_POST['adprotektor_site_key_clear'])) !== '') { return ''; } $value = sanitize_text_field((string) $value); return $value !== '' ? $value : (string) get_option('adprotektor_site_key', ''); }, 'default' => '', )); // First-party tracking (adblock bypass) — relays script + events through // this site's own origin. Requires a site key (used to forward visitor // IP/JA4). See adprotektor-firstparty.php. register_setting('adprotektor_settings', 'adprotektor_firstparty_enabled', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false, )); // Whether a trusted edge (Cloudflare/nginx) fronts this site. Gates // trusting forwarded visitor IP/JA4 — off by default so a bare host // never trusts visitor-supplied headers. register_setting('adprotektor_settings', 'adprotektor_trusted_edge', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false, )); // Inbound headers carrying the visitor JA4 / JA4H, populated by the // customer's edge (Cloudflare Transform Rule / nginx ja4). Optional. register_setting('adprotektor_settings', 'adprotektor_ja4_header', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'X-Adp-JA4', )); register_setting('adprotektor_settings', 'adprotektor_ja4h_header', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'X-Adp-JA4H', )); // Optional "Protected by AdProtektor" footer badge. OFF unless the site // owner turns it on (wordpress.org: no credit links without opt-in). The // optional referral code (Account → Refer & earn) credits signups from // the badge to the site owner. register_setting('adprotektor_settings', 'adprotektor_badge_enabled', array( 'type' => 'boolean', 'sanitize_callback' => array($this, 'sanitize_checkbox'), 'default' => false, )); register_setting('adprotektor_settings', 'adprotektor_badge_ref', array( 'type' => 'string', 'sanitize_callback' => array($this, 'sanitize_referral_code'), 'default' => '', )); } public function sanitize_referral_code($value) { $code = strtolower(trim((string) $value)); return preg_match('/^[a-z0-9]{4,16}$/', $code) ? $code : ''; } /** The opt-in footer badge; prints nothing unless the owner enabled it. */ public function render_badge() { if (!get_option('adprotektor_badge_enabled')) { return; } $args = array('utm_source' => 'wordpress-badge', 'utm_medium' => 'referral'); $ref = (string) get_option('adprotektor_badge_ref', ''); if ($ref !== '') { $args['via'] = $ref; } $url = add_query_arg($args, 'https://adprotektor.app/'); printf( '

%s

', esc_url($url), esc_html__('Protected by AdProtektor', 'adprotektor') ); } public function sanitize_checkbox($value) { return !empty($value) ? 1 : 0; } public function add_settings_link($links) { $settings_link = 'Settings'; array_unshift($links, $settings_link); return $links; } public function inject_tracking_script() { $tracking_code = get_option('adprotektor_tracking_code', ''); if (empty($tracking_code)) { return; } // Skip attributes that tell JS optimizers / Cloudflare Rocket Loader to // leave the tracking tag exactly where it is. A minify/combine plugin that // rewrites this