prefix . 'selectika_events'; $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table (event_id varchar(36) NOT NULL, session_hash char(64) NOT NULL, generation varchar(36) NOT NULL, payload longtext NOT NULL, created_at bigint unsigned NOT NULL, PRIMARY KEY (event_id), KEY session_events (session_hash,generation,created_at), KEY expiry (created_at)) $charset;" ); } /** * Resolve a non-reversible session key; never store raw WooCommerce customer identifiers. * * @return string Session HMAC. */ private static function session_hash() { return hash_hmac( 'sha256', (string) WC()->session->get_customer_id(), wp_salt( 'nonce' ) ); } /** Expire ephemeral cart events independently of WooCommerce's mutable cart session. */ public static function cleanup() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Private ephemeral queue has no WordPress cache; indexed expiry removes only plugin events. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at<%d', $wpdb->prefix . 'selectika_events', time() - HOUR_IN_SECONDS ) ); } /** * Drain this session's events without dirtying or overwriting WooCommerce cart state. * * @return array Allowlisted events from the current connection generation. */ private static function drain() { global $wpdb; $c = Selectika_Connection::get(); $table = $wpdb->prefix . 'selectika_events'; $session = self::session_hash(); self::cleanup(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read live private session events; values and identifiers are prepared and no WooCommerce session write occurs. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT event_id,payload FROM %i WHERE session_hash=%s AND generation=%s ORDER BY created_at,event_id LIMIT 40', $table, $session, $c['generation'] ), ARRAY_A ); $events = array(); foreach ( $rows as $row ) { $event = json_decode( $row['payload'], true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic dequeue: only the request that removes the row may return it, including simultaneous tabs; preserve concurrent inserts. $claimed = $wpdb->delete( $table, array( 'event_id' => $row['event_id'], 'session_hash' => $session, ) ); if ( 1 === $claimed && is_array( $event ) ) { $events[] = $event; } } return $events; } /** * Check consent for this connection generation. */ public static function consent() { $c = Selectika_Connection::get(); return ! empty( $c['generation'] ) && isset( $_COOKIE['selectika_analytics_consent'] ) && is_string( $_COOKIE['selectika_analytics_consent'] ) && hash_equals( $c['generation'], sanitize_text_field( wp_unslash( $_COOKIE['selectika_analytics_consent'] ) ) ); } /** * Create an allowlisted product line for browser commerce analytics. * * @param WC_Product $product Native product object. * @param float $quantity Item quantity. */ public static function item( $product, $quantity ) { $parent = $product->is_type( 'variation' ) ? wc_get_product( $product->get_parent_id() ) : $product; if ( ! $parent ) { return null; } return array( 'item_id' => Selectika_Connection::item_id( $parent ), 'item_name' => $parent->get_name(), 'item_sku' => $product->get_sku(), 'item_variant' => (string) ( $product->is_type( 'variation' ) ? $product->get_id() : 0 ), 'product_id' => (string) $parent->get_id(), 'variation_id' => (string) ( $product->is_type( 'variation' ) ? $product->get_id() : 0 ), 'price' => (float) wc_get_price_to_display( $product ), 'quantity' => (float) $quantity, ); } /** * Buffer a native cart event without mutating WooCommerce session storage. * * @param string $name Event or attribute name. * @param int $product_id Native parent product ID. * @param float $quantity Item quantity. */ private function buffer( $name, $product_id, $quantity ) { if ( ! Selectika_Connection::active( 'tracking' ) || ! self::consent() || ! function_exists( 'WC' ) || ! WC()->session ) { return; } $product = wc_get_product( $product_id ); if ( ! $product ) { return; } $item = self::item( $product, $quantity ); if ( ! $item ) { return; } global $wpdb; $c = Selectika_Connection::get(); $event = array( 'event_id' => wp_generate_uuid4(), 'event_name' => $name, 'occurred_at' => gmdate( 'c' ), 'currency' => get_woocommerce_currency(), 'value' => $item['price'] * $item['quantity'], 'items' => array( $item ), ); self::cleanup(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Append to a private ephemeral queue; never mark WooCommerce's whole mutable cart session dirty. $wpdb->insert( $wpdb->prefix . 'selectika_events', array( 'event_id' => $event['event_id'], 'session_hash' => self::session_hash(), 'generation' => $c['generation'], 'payload' => wp_json_encode( $event ), 'created_at' => time(), ) ); } /** * Capture a confirmed native WooCommerce add-to-cart event. * * @param string $key Native cart item key. * @param int $product_id Native parent product ID. * @param float $quantity Item quantity. * @param int $variation_id Optional native variation ID. */ public function added( $key, $product_id, $quantity, $variation_id = 0 ) { $this->buffer( 'add_to_cart', $variation_id ? $variation_id : $product_id, $quantity ); } /** * Capture a native WooCommerce cart removal event. * * @param string $key Native cart item key. * @param WC_Cart $cart Current WooCommerce cart. */ public function removed( $key, $cart ) { $item = $cart->get_cart_item( $key ); if ( $item ) { $this->buffer( 'remove_from_cart', $item['variation_id'] ? $item['variation_id'] : $item['product_id'], $item['quantity'] ); } } /** * Return fresh private cart context after exact-origin and consent checks. */ public function context() { // Custom header forces CORS preflight; same-origin validation prevents cross-site draining. // This anonymous cart endpoint does not accept WordPress user credentials or mutations. $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; $parts = wp_parse_url( home_url() ); $expected = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); $method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; $header = isset( $_SERVER['HTTP_X_SELECTIKA_REQUEST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_SELECTIKA_REQUEST'] ) ) : ''; nocache_headers(); header( 'Cache-Control: private, no-store, max-age=0' ); if ( 'POST' !== $method || '1' !== $header || ! hash_equals( $expected, $origin ) || ! Selectika_Connection::active() || ! self::consent() ) { wp_send_json_error( array( 'code' => 'selectika_context_denied' ), 403 ); } if ( ! WC()->cart || ! WC()->session ) { wp_send_json_error( array( 'code' => 'selectika_cart_unavailable' ), 503 ); } $items = array(); foreach ( WC()->cart->get_cart() as $row ) { if ( $row['data'] ) { $item = self::item( $row['data'], $row['quantity'] ); if ( $item ) { $items[] = $item; } } } $selection = null; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only anonymous context: exact Origin and mandatory custom header are checked above; no reusable nonce enters cached HTML. $product_id = isset( $_POST['product_id'] ) && is_scalar( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Same-origin read-only context, validated above. $variation_id = isset( $_POST['variation_id'] ) && is_scalar( $_POST['variation_id'] ) ? absint( wp_unslash( $_POST['variation_id'] ) ) : 0; if ( $product_id ) { $parent = wc_get_product( $product_id ); $selected = wc_get_product( $variation_id ? $variation_id : $product_id ); if ( ! $parent || ! $selected || 'publish' !== $parent->get_status() || 'publish' !== $selected->get_status() || ( $variation_id && $selected->get_parent_id() !== $product_id ) || ! $selected->is_purchasable() || ! $selected->is_in_stock() ) { wp_send_json_error( array( 'code' => 'selectika_selection_unavailable' ), 400 ); } $attributes = array(); foreach ( $selected->is_type( 'variation' ) ? $selected->get_variation_attributes() : array() as $key => $value ) { if ( '' === $value ) { wp_send_json_error( array( 'code' => 'selectika_selection_requires_options' ), 400 ); } $attributes[] = array( 'attribute' => str_replace( 'attribute_pa_', 'pa_', $key ), 'value' => $value, ); } if ( $parent->is_type( 'variable' ) && ! $variation_id ) { wp_send_json_error( array( 'code' => 'selectika_selection_requires_options' ), 400 ); } $selection = array( 'id' => $selected->get_id(), 'variation' => $attributes, ); } $c = Selectika_Connection::get(); $events = self::drain(); $purchase = null; if ( ! Selectika_Connection::active( 'orders' ) ) { // Thank-you context is supplied through the caller's order key, never shared HTML. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Exact Origin and custom header checked above; order key is independently verified below. $order_key = isset( $_POST['order_key'] ) && is_string( $_POST['order_key'] ) ? sanitize_text_field( wp_unslash( $_POST['order_key'] ) ) : ''; $order = $order_key ? wc_get_order( wc_get_order_id_by_order_key( $order_key ) ) : null; if ( $order && 'yes' === $order->get_meta( '_selectika_analytics_consent' ) && $c['generation'] === $order->get_meta( '_selectika_connection_generation' ) && $order->is_paid() && hash_equals( $order->get_order_key(), $order_key ) ) { $purchase = Selectika_Orders::payload( $order, 'order_paid' ); } } wp_send_json( array( 'generation' => $c['generation'], 'site_id' => $c['site_id'], 'events' => $events, 'cart' => array( 'currency' => get_woocommerce_currency(), 'items' => $items, 'value' => (float) WC()->cart->get_cart_contents_total(), ), 'purchase' => $purchase, 'cart_nonce' => wp_create_nonce( 'wc_store_api' ), 'selection' => $selection, ) ); } }