prefix}improveab_ and binds every value, so the direct-query // and caching sniffs do not apply. The prepared-SQL sniffs are not switched // off for the file, so a query someone adds later that interpolates anything // else is reported. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom tables, read and written directly on purpose; the prepared-SQL sniffs stay on. /** * Registers the public tracking route and the admin management routes. */ class ImproveAB_REST { const NS = 'improveab/v1'; const RATE_LIMIT = 600; // Default max events per window per client IP. const RATE_WINDOW = 60; // Seconds. const MAX_BODY_BYTES = 4096; // Tracking payloads are tiny; anything bigger is not ours. // A page's events travel together ({events: [...]}): as many as a page can // carry tests, each still under the single event's own size. const MAX_ENVELOPE_BYTES = 16384; const MAX_ENVELOPE_EVENTS = 50; /** * The origin the current /event request came from, judged once per * request: '' when it is this site's own page (or the browser named * none), else the host of the page that sent it ('cross-site' when the * browser said so without naming one). * * @var string */ private $foreign_origin = ''; /** * Keys this request holds through claim_wait(), each with how many * calls hold it. * * @var int[] */ private static $held = array(); /** * Register hooks. */ public function hooks() { add_action( 'rest_api_init', array( $this, 'register' ) ); // An order not paid at its thank-you page counts once it is (a bank // transfer marked paid, a gateway confirming later, a failed payment // retried); a credited order's revenue follows the order (a refund // comes off it, a deleted refund goes back, a cancelled or failed // order carries none until it is paid again); a trashed order's // places go. A refund is deleted from the order screen or through // WooCommerce's REST API. add_action( 'woocommerce_order_status_changed', array( __CLASS__, 'order_changed' ) ); add_action( 'woocommerce_payment_complete', array( __CLASS__, 'order_changed' ) ); add_action( 'woocommerce_order_refunded', array( __CLASS__, 'order_changed' ) ); add_action( 'woocommerce_refund_deleted', array( __CLASS__, 'refund_deleted' ), 10, 2 ); add_action( 'woocommerce_rest_delete_shop_order_refund_object', array( __CLASS__, 'rest_refund_deleted' ) ); add_action( 'woocommerce_trash_order', array( __CLASS__, 'order_trashed' ) ); } /** * Register all routes. */ public function register() { register_rest_route( self::NS, '/event', array( 'methods' => 'POST', 'callback' => array( $this, 'record_event' ), 'permission_callback' => '__return_true', ) ); $admin_perm = array( $this, 'admin_permission' ); $settings_perm = array( $this, 'settings_permission' ); register_rest_route( self::NS, '/tests', array( array( 'methods' => 'GET', 'callback' => array( $this, 'list_tests' ), 'permission_callback' => $admin_perm, ), array( 'methods' => 'POST', 'callback' => array( $this, 'create_test' ), 'permission_callback' => $admin_perm, ), ) ); register_rest_route( self::NS, '/tests/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_test' ), 'permission_callback' => $admin_perm, ), array( 'methods' => 'POST', 'callback' => array( $this, 'update_test' ), 'permission_callback' => $admin_perm, ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_test' ), 'permission_callback' => $admin_perm, ), ) ); register_rest_route( self::NS, '/tests/(?P\d+)/status', array( 'methods' => 'POST', 'callback' => array( $this, 'set_status' ), 'permission_callback' => $admin_perm, ) ); register_rest_route( self::NS, '/tests/(?P\d+)/results', array( 'methods' => 'GET', 'callback' => array( $this, 'get_results' ), 'permission_callback' => $admin_perm, ) ); register_rest_route( self::NS, '/tests/(?P\d+)/timeseries', array( 'methods' => 'GET', 'callback' => array( $this, 'get_timeseries' ), 'permission_callback' => $admin_perm, ) ); register_rest_route( self::NS, '/tests/(?P\d+)/summary-email', array( 'methods' => 'POST', 'callback' => array( $this, 'email_summary' ), 'permission_callback' => $admin_perm, ) ); register_rest_route( self::NS, '/content', array( 'methods' => 'GET', 'callback' => array( $this, 'list_content' ), 'permission_callback' => $admin_perm, ) ); register_rest_route( self::NS, '/tests/(?P\d+)/duplicate', array( 'methods' => 'POST', 'callback' => array( $this, 'duplicate_test' ), 'permission_callback' => $admin_perm, ) ); register_rest_route( self::NS, '/prefs', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_prefs' ), 'permission_callback' => $admin_perm, ), array( 'methods' => 'POST', 'callback' => array( $this, 'update_prefs' ), 'permission_callback' => $admin_perm, ), ) ); register_rest_route( self::NS, '/settings', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'permission_callback' => $settings_perm, ), array( 'methods' => 'POST', 'callback' => array( $this, 'update_settings' ), 'permission_callback' => $settings_perm, ), ) ); } /** * Capability check for the test-management routes: administrators, plus * any role granted the manage-tests capability. * * @return bool */ public function admin_permission() { return improveab_can_manage(); } /** * Capability check for settings: administrators only, always. * * @return bool */ public function settings_permission() { return current_user_can( 'manage_options' ); } /* --------------------------------------------------------------------- */ /* Public tracking */ /* --------------------------------------------------------------------- */ /** * Record an impression or conversion. Public, heavily validated. * * A body is one event, or a page's events together as {events: [...]} * (CONTRACTS 5.1): the request-wide checks run once, then each event as * if it had come alone, and the answer lists each one's outcome. * * @param WP_REST_Request $request Request. * @param array|null $item One event of an envelope (internal). * @return WP_REST_Response */ public function record_event( $request, $item = null ) { if ( is_array( $item ) ) { $body = $item; } else { $raw = (string) $request->get_body(); // Cheap size gate before any parsing: a legitimate event (including a // cross-domain handoff token) fits comfortably in a few KB, a page's // envelope in a few times that. Oversized bodies on this public // route are dropped without json_decode work. if ( '' === $raw || strlen( $raw ) > self::MAX_ENVELOPE_BYTES ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } $body = json_decode( $raw, true ); if ( ! is_array( $body ) ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } $events = isset( $body['events'] ) && is_array( $body['events'] ) && array_values( $body['events'] ) === $body['events'] ? $body['events'] : null; if ( null === $events && strlen( $raw ) > self::MAX_BODY_BYTES ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } if ( null !== $events && ( empty( $events ) || count( $events ) > self::MAX_ENVELOPE_EVENTS ) ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // Silently ignore obvious bots and automation. if ( $this->is_bot() ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // Optionally ignore all logged-in users (site-wide setting). $settings = ImproveAB_Settings::get(); if ( ! empty( $settings['exclude_logged_in'] ) && is_user_logged_in() ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // Each event counts against the address's budget, however they travel. if ( ! $this->within_rate_limit( null === $events ? 1 : count( $events ) ) ) { return new WP_REST_Response( array( 'recorded' => false ), 429 ); } $this->foreign_origin = $this->foreign_origin(); if ( null !== $events ) { $results = array(); foreach ( $events as $one ) { // An item that is not an event object (or is an envelope // itself) records nothing. $results[] = is_array( $one ) && ! isset( $one['events'] ) && true === $this->record_event( $request, $one )->get_data()['recorded']; } return new WP_REST_Response( array( 'recorded' => in_array( true, $results, true ), 'results' => $results ), 200 ); } } // Every field passes a scalar guard first: json_decode() hands back // arrays for nested values, and sanitize_title() on an array is a fatal // TypeError on PHP 8. A visitor must never be able to crash a public // route, so a non-scalar simply reads as empty and is rejected below // like any other malformed field. $field = static function ( $key ) use ( $body ) { return ( isset( $body[ $key ] ) && is_scalar( $body[ $key ] ) ) ? (string) $body[ $key ] : ''; }; $slug = sanitize_title( $field( 'test' ) ); $variant = sanitize_text_field( $field( 'variant' ) ); $type = sanitize_text_field( $field( 'type' ) ); $token = sanitize_text_field( $field( 'vid' ) ); // Clamped to the column's own ceiling (decimal(14,2)). WordPress runs // MySQL without strict mode, so a larger number is stored as the // maximum with only a warning - and on other engines it is stored in // full - either way a revenue figure nobody can explain. Bounding it // here makes the ceiling explicit and the same everywhere. $value = ( isset( $body['value'] ) && is_numeric( $body['value'] ) ) ? min( 999999999999.99, max( 0, (float) $body['value'] ) ) : 0.0; $url = self::event_path( $field( 'url' ) ); $segment = sanitize_text_field( $field( 'seg' ) ); if ( ! in_array( $segment, array( 'desktop', 'mobile', 'tablet' ), true ) ) { $segment = ''; } // Clamp identifiers to their column sizes so junk can never trigger // silent truncation or oversized index comparisons. $slug = substr( $slug, 0, 191 ); $variant = substr( $variant, 0, 40 ); $token = substr( preg_replace( '/[^a-zA-Z0-9\-]/', '', $token ), 0, 64 ); // Cross-domain handoff: an event relayed from a variant page on another // domain carries a signed instruction that only this site could mint. // Its presence and validity stand in for the same-origin checks (who // takes part was decided client-side when the visitor was assigned // here), and it binds the event to a real test/goal that this site // authored. $handoff = isset( $body['abx'] ) ? improveab_verify( $body['abx'], 'ho' ) : false; $is_handoff = is_array( $handoff ); // Only the runtime acting on a handoff link sends an instruction at // all, and a link is something anybody can craft (the runtime cannot // check a signature: only this site can). One that does not verify is // somebody's words, not a visitor's report: it files nothing, not even // as the ordinary event it would otherwise pass for. if ( isset( $body['abx'] ) && ! $is_handoff ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } if ( $is_handoff ) { // The instruction must reference this very event's test and point // back at this site's own endpoint. $expected_endpoint = rest_url( self::NS . '/event' ); if ( ! isset( $handoff['s'] ) || $handoff['s'] !== $slug || ( isset( $handoff['e'] ) && untrailingslashit( (string) $handoff['e'] ) !== untrailingslashit( $expected_endpoint ) ) ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } } if ( ! in_array( $type, array( 'impression', 'conversion' ), true ) || '' === $slug || '' === $variant || '' === $token ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // Revenue is only trusted when it carries a valid server-minted token. // The runtime attaches this for WooCommerce purchase conversions, where // the amount is derived server-side. Any other client-supplied value is // discarded so it cannot be inflated. Cross-domain handoffs never carry // revenue (the amount is signed for this origin only). // An impression never carries revenue (every reader sums conversion rows // only): a valid purchase token on one would be stored as dead data and // would skip the once-per-order claim that only conversions take. if ( 'conversion' !== $type || $is_handoff ) { $value = 0.0; } // A page on another site cannot file events for this one: /event is a // simple request any page may send, from each of its own visitors' // addresses, so the per-address limit would never bite and that page // could choose a test's result. A verified handoff is the exception // (the variant page on another host sends it by design), but only from // a host of that test's own variant pages, and never from this site's // own (below). if ( '' !== $this->foreign_origin && ! $is_handoff ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // The running tests by slug, kept with the visitor sets (one cached // read, not two queries per event). $test = ImproveAB_Tests::event_test( $slug ); if ( ! $test ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } $test_id = (int) $test['id']; if ( $is_handoff ) { // A handoff is posted from the variant's page on the far domain // (CONTRACTS 8.4), never from a page of this site: the entry page // prints the signed instruction for every visitor, so a beacon // that comes from this site's own page is the runtime acting on a // link anybody can craft (?abx=&abv=v1& // abvid=), which a hidden frame on any other site can // open for each of its visitors. if ( self::from_own_site() ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // And from a host of that test's variant addresses: a sender that // names no origin at all (Origin: null, which a sandboxed frame // sends, with Sec-Fetch-Site: cross-site) cannot show that. if ( '' !== $this->foreign_origin && ( 'cross-site' === $this->foreign_origin || ! improveab_host_within( $this->foreign_origin, $test['h'] ) ) ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } } // Who takes part, re-checked for logged-in users (cache-safe: the // REST request always reflects the real current user). Skipped for // cross-domain handoffs: the visitor is on another site, so the current // user here is irrelevant and the rule was already applied at assignment. if ( ! $is_handoff ) { // Every visitor but staff, the rule runtime.js shows versions by. if ( current_user_can( 'edit_posts' ) ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } } // Variant must belong to the test, spelled exactly as stored: the // column's collation ignores case (and accents, on MySQL), so DEFAULT // would find the row of default and the visitor's spelling would be // stored as a variant nothing reports. if ( ! in_array( $variant, $test['k'], true ) ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } global $wpdb; // A conversion counts only under the variant this visitor's impression // recorded: the runtime always converts under its stored assignment, so // a mismatch is a client filing conversions on a variant it never saw. $imply_impression = false; // The order's lock while this event works with it, and what the order // carries now when this conversion is to be credited from it. $lock = ''; $money = null; if ( 'conversion' === $type ) { $imp_variant = $wpdb->get_var( $wpdb->prepare( "SELECT variant_key FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND visitor_token = %s AND event_type = 'impression'", $test_id, $token ) ); if ( null === $imp_variant ) { // Nothing on record yet. Impressions and conversions are two // independent fire-and-forget beacons, so over a real network // either can arrive first - measured at about 2% of visitors on // a CDN-fronted site, and always the ones who converted // fastest. Refusing the conversion would quietly under-report // exactly the visitors a winning variant produces, so the // impression it implies is recorded alongside it instead. // Nothing is loosened by this: the variant is checked against // the test below either way, and a client willing to file a // pair it never saw could always have sent both beacons. The // real impression arriving a moment later is INSERT IGNOREd // against the unique key, so the visitor is still counted once. $imply_impression = true; $imp_variant = $variant; } if ( (string) $imp_variant !== $variant ) { return new WP_REST_Response( array( 'recorded' => false ), 200 ); } // The purchase's proof, minted on the order's thank-you page for the // running tests with the purchase goal (CONTRACTS 7): `rev` for an // order WooCommerce counts as paid, carrying its revenue, `pend` for // one not paid yet (a bank transfer on hold, a gateway that has not // confirmed, a failed or cancelled payment the shopper may still // make). A proof for another test, or any other value, is none: the // value is zeroed. $proof = false; if ( ! $is_handoff && isset( $body['vsig'] ) ) { $proof = improveab_verify( $body['vsig'], 'rev' ); if ( ! is_array( $proof ) ) { $proof = improveab_verify( $body['vsig'], 'pend' ); } } $order_id = 0; $pending = false; if ( is_array( $proof ) && ! empty( $proof['o'] ) && isset( $proof['t'] ) && is_array( $proof['t'] ) && in_array( $test_id, array_map( 'intval', $proof['t'] ), true ) ) { if ( 'rev' === $proof['y'] && $value > 0 && isset( $proof['v'] ) && abs( (float) $proof['v'] - $value ) <= 0.001 ) { $order_id = absint( $proof['o'] ); } elseif ( 'pend' === $proof['y'] ) { $order_id = absint( $proof['o'] ); $pending = true; } } if ( ! $order_id ) { $value = 0.0; } // A thank-you page names the order (/checkout/order-received/1535/), // and the order holds the buyer's name and email: a purchase's rows // (this one and the impression it implies) keep the page's path // without the order's number, whichever way the proof is used. if ( is_array( $proof ) && ! empty( $proof['o'] ) ) { $url = self::without_order( $url, absint( $proof['o'] ) ); } // The impression a racing conversion implies, written first so the // pair is never half-recorded, and before anything else this beacon // does: a payment credited a moment later (sync_order()) // matches the visitor by it. Same dedup as below: a real impression // that lands afterwards changes nothing. if ( $imply_impression ) { $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}improveab_events (test_id, variant_key, event_type, visitor_token, segment, value, url, created_at) VALUES (%d, %s, 'impression', %s, %s, 0, %s, UTC_TIMESTAMP())", $test_id, $variant, $token, $segment, $url ) ); } // Everything done with the order happens under its lock, which // crediting its payment, a refund and the trash take as well, so // each finds the others' work whole: a place kept here is there when // the payment reads the places, or the order is seen paid here. $order = false; $locked = false; $wait = false; if ( $order_id > 0 ) { $lock = 'improveab_vs_order_' . $order_id; $locked = self::claim_wait( $lock ); if ( ! $locked ) { $lock = ''; } // A credit is only ever taken holding the lock; the order is // loaded without it too, to see whether it is paid (an order // that is not keeps the place either way). $order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : false; if ( $order && ! method_exists( $order, 'is_paid' ) ) { // A refund's id, never an order's. $order = false; } if ( $order ) { // What another request wrote a moment ago. $order->read_meta_data( true ); // An order refunded in full or in the trash is no purchase, // as on its own thank-you page: no conversion is recorded // and no place is kept (a proof minted before is spent on // nothing; the impression it implies stays, a visit). if ( $order->has_status( array( 'refunded', 'trash' ) ) ) { self::let_go( $lock ); return new WP_REST_Response( array( 'recorded' => false ), 200 ); } } // Not paid now: an order still waiting (`pend`), or one whose // revenue proof was signed while it was paid and that the shop // has cancelled or failed since (cash on delivery never // collected). A waiting proof also waits when the order was // held past the wait. $wait = $pending ? ! ( $locked && $order && $order->is_paid() ) : ( $order && ! $order->is_paid() ); } // An order not paid counts once it is paid (decision 22): no // conversion now. The visitor's place is kept on the order and the // conversion is written, with the order's revenue, when WooCommerce // reports it paid (sync_order()). An order the shop cannot load // can never be paid: nothing is recorded. When the order was held // past the wait (another request on it for seconds), the place is // kept all the same rather than lost: crediting always takes the // lock, so the payment, or the order's next status change when it // is paid already, writes the conversion, once. if ( $wait ) { if ( $order ) { self::keep_place( $order, $test_id, $token, $variant, $segment, $url ); } self::let_go( $lock ); return new WP_REST_Response( array( 'recorded' => (bool) $order ), 200 ); } // One revenue credit per order per test, ever. A reload of the // thank-you page mints a fresh token, so without this one real order // could be replayed under fresh visitor ids to multiply a variant's // revenue. The credit is recorded on the order itself (it lives and // goes with the order), read and written under the order's lock. A // visitor in several tests still credits each once; a repeat still // counts as a conversion, only its value is zeroed. The revenue is // the order's own now, net of refunds; an order paid since its page // carried `pend` is credited like a paid one. Without the lock no // credit is taken: the conversion is recorded without revenue. if ( $order_id > 0 ) { if ( $order && $locked && ! in_array( $test_id, self::credited_tests( $order ), true ) ) { $money = self::order_money( $order ); $value = $pending ? $money['value'] : min( $value, $money['value'] ); } else { $value = 0.0; } } } // INSERT IGNORE relies on the unique (test_id, visitor_token, event_type) index for dedup. // The time is the database's own clock (UTC), as for the implied // impression above: several web servers with clocks apart then still // write the rows of one moment with one time. $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}improveab_events (test_id, variant_key, event_type, visitor_token, segment, value, url, created_at) VALUES (%d, %s, %s, %s, %s, %f, %s, UTC_TIMESTAMP())", $test_id, $variant, $type, $token, $segment, $value, $url ) ); $inserted = (bool) $wpdb->rows_affected; // The order's credit for this test is taken only once the conversion // was actually written; a de-duplicated one leaves it available. The // record names the conversion, so a refund later finds it. if ( null !== $money && $inserted ) { // A row of its own, never a rewrite of the list: two tests // credited from one order at the same moment each loaded the order // before the other wrote, and a rewrite kept only one. $at = (string) $wpdb->get_var( $wpdb->prepare( "SELECT created_at FROM {$wpdb->prefix}improveab_events WHERE id = %d", (int) $wpdb->insert_id ) ); $order->add_meta_data( '_improveab_credited', self::credit_record( $test_id, $token, $variant, $segment, $at, $value, $money['refunds'] ) ); $order->save_meta_data(); } self::let_go( $lock ); /** * Fires after a tracking event has passed every validation step and * has been written (or de-duplicated by the unique index). * * For integrations that want to see each event as it is counted, * without touching the endpoint. No personal data is included. * * @param int $test_id Test ID. * @param array $event { variant, type, token, segment, value, url, handoff }. * @param bool $inserted True if a new row was written; false if the * event was de-duplicated. */ do_action( 'improveab_event_recorded', $test_id, array( 'variant' => $variant, 'type' => $type, 'token' => $token, 'segment' => $segment, 'value' => $value, 'url' => $url, 'handoff' => $is_handoff, ), $inserted ); return new WP_REST_Response( array( 'recorded' => true ), 200 ); } /** * The page an event happened on, as the table keeps it: the path alone. * The runtime sends location.pathname, but the route takes whatever a * client sends, and an address beyond the path (a query string with an * email address in it, a fragment, another site's address) would sit in * the events table beside a pseudonymous visitor id for as long as the * rows are kept, while CONTRACTS says the column is a page path and the * event hook "no personal data". Cut to the column's 255 bytes at a whole * character: the insert would be refused (SQLite) or stored cut short * (MySQL, with only a warning) otherwise, the answer still "recorded". * * @param string $raw What the client sent. * @return string The path, '' when there is none. */ private static function event_path( $raw ) { $path = wp_parse_url( (string) $raw, PHP_URL_PATH ); if ( ! is_string( $path ) || '' === $path ) { return ''; } // esc_url_raw() adds a scheme to anything that does not start with a // slash, so the path is given its slash first. $path = substr( esc_url_raw( '/' . ltrim( $path, '/' ) ), 0, 255 ); while ( '' !== $path && ! preg_match( '//u', $path ) ) { $path = substr( $path, 0, -1 ); } return $path; } /** * A page path without an order's number: where it stands as a whole * number (not inside a longer one) it goes, and the slash it leaves is * not doubled. * * @param string $path Page path. * @param int $order_id Order id the purchase's proof names. * @return string */ private static function without_order( $path, $order_id ) { $path = (string) preg_replace( '/(? 'shop_order_refund', 'parent' => $order->get_id(), 'limit' => -1, ) ) : array(); foreach ( (array) $list as $refund ) { if ( is_object( $refund ) && method_exists( $refund, 'get_amount' ) ) { $ids[] = (int) $refund->get_id(); $taken += abs( (float) $refund->get_amount() ); } } return array( 'value' => min( 999999999999.99, max( 0, round( (float) $order->get_total() - $taken, 2 ) ) ), 'refunds' => $ids, ); } /** * An order changed status, was paid or had a refund recorded * (sync_order()). * * @param int $order_id Order id. */ public static function order_changed( $order_id ) { self::sync_order( $order_id ); } /** * A refund deleted from the order screen (sync_order()). * * @param int $refund_id Refund id. * @param int $order_id Order id. */ public static function refund_deleted( $refund_id, $order_id ) { self::sync_order( $order_id ); } /** * A refund deleted through WooCommerce's REST API (sync_order()). * * @param WC_Order_Refund $refund The deleted refund. */ public static function rest_refund_deleted( $refund ) { if ( is_object( $refund ) && method_exists( $refund, 'get_parent_id' ) ) { self::sync_order( (int) $refund->get_parent_id() ); } } /** * An order in the trash: its places go (it is no longer waiting for a * payment). * * @param int $order_id Order id. */ public static function order_trashed( $order_id ) { self::sync_order( $order_id, true ); } /** * Bring what an order keeps up to date with the order as it is now, * after any of WooCommerce's signals about it (a status change, a * payment, a refund, a deleted refund, the trash), holding its lock. * * Paid (any status WooCommerce counts as paid, also after a failed or * cancelled spell, through the Pay link or a shop moving it back): the * conversions waiting for it are written (decision 22), one per place * the order keeps (`_improveab_pending`, one per test, kept by the * thank-you page's `pend` proof), under the visitor's version and * device, with the order's revenue, de-duplicated like every conversion * (one per visitor per test), each test credited once per order as ever * (the same `_improveab_credited` record as a paid order's own * conversion). Written now, as a new event, so the tally job sums it like * any other. A place whose visitor has converted already, or has no * impression left (a deleted test, or a stopped one the rollup * compacted), is dropped: one conversion per visitor, and none that * cannot be matched to the visitor any more. Failed or cancelled: the * places stay, since it can still be paid. Refunded in full or trashed: * they go. * * And every conversion credited from the order carries what the order * brings now (settle_credits()), so a refund, a deleted refund, a * cancellation and a payment after it each move its revenue, in every * figure, once. * * A store's every order passes here: the lock is taken only for an * order that holds a place or a credit, or may be about to (touched()). * * @param int $order_id Order id. * @param bool $trashed The order was moved to the trash (as the hook * says, whatever status the order reads with). */ private static function sync_order( $order_id, $trashed = false ) { $order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : false; // A refund's own id names no order of ours. if ( ! $order || ! method_exists( $order, 'is_paid' ) || ! self::touched( $order ) ) { return; } $lock = 'improveab_vs_order_' . (int) $order->get_id(); if ( ! self::claim_wait( $lock ) ) { // Held past the wait: what the order keeps stays for its next // change. return; } $places = self::places( $order ); $trashed = $trashed || $order->has_status( 'trash' ); // The credits first: the places below add credits that carry the // order's revenue already, and no record without its row id may be // rewritten (update_meta_data() would empty every row of the key). $changed = ! $trashed && self::settle_credits( $order ); if ( $trashed || $order->has_status( 'refunded' ) ) { foreach ( $places as $p ) { $order->delete_meta_data_by_mid( $p['mid'] ); $changed = true; } } elseif ( $places && $order->is_paid() ) { $money = self::order_money( $order ); $done = self::credited_tests( $order ); foreach ( $places as $p ) { $place = $p['place']; if ( $place && ! in_array( $place['test'], $done, true ) ) { $row = self::write_conversion( $place['test'], $place['vid'], $place['variant'], $place['segment'], $money['value'], $place['url'] ); if ( false === $row ) { // The database refused it: the place stays for the // order's next status change. continue; } if ( $row ) { $order->add_meta_data( '_improveab_credited', self::credit_record( $place['test'], $place['vid'], $row['variant'], $row['segment'], $row['at'], $money['value'], $money['refunds'] ) ); $done[] = $place['test']; } } $order->delete_meta_data_by_mid( $p['mid'] ); $changed = true; } } if ( $changed ) { $order->save_meta_data(); } self::let_go( $lock ); } /** * Bring every conversion credited from an order to what the order brings * now (decision 22): its total less every refund on record, and nothing * from the moment WooCommerce moves it to cancelled or failed (cash on * delivery never collected, a paid order cancelled) until it is paid * again; on hold or pending again in between, it stays at nothing. The * conversion stays either way. A refund recorded comes off, a refund * deleted goes back (also one deleted some other way, at the order's * next change), each once, in the event row and in every sum that holds * it (ImproveAB_Install::set_revenue()). Called holding the order's lock, * its meta read afresh. * * @param WC_Order $order Order. * @return bool Whether a credit record changed (the caller saves them). */ private static function settle_credits( $order ) { $void = $order->has_status( array( 'cancelled', 'failed' ) ); $paid = $order->is_paid(); $money = null; $changed = false; foreach ( (array) $order->get_meta( '_improveab_credited', false ) as $meta ) { $record = is_object( $meta ) && isset( $meta->value ) ? $meta->value : null; // A record from before the conversion was named on it (a list of // test ids) cannot be found again, and one not saved yet has no // row to rewrite: both are left as they are. if ( ! is_array( $record ) || ! isset( $record['test'], $record['vid'] ) || empty( $meta->id ) ) { continue; } if ( null === $money ) { $money = self::order_money( $order ); } $unpaid = $void || ( ! $paid && ! empty( $record['unpaid'] ) ); $refunds = isset( $record['refunds'] ) ? array_map( 'intval', (array) $record['refunds'] ) : array(); $left = ImproveAB_Install::set_revenue( (int) $record['test'], (string) $record['vid'], $unpaid ? 0.0 : $money['value'], $record ); if ( false === $left ) { // The database refused it: left for the order's next change. continue; } sort( $refunds ); $now = $money['refunds']; sort( $now ); if ( abs( $left - ( isset( $record['value'] ) ? (float) $record['value'] : 0.0 ) ) < 0.005 && $refunds === $now && ! empty( $record['unpaid'] ) === $unpaid ) { continue; } $record['value'] = $left; $record['refunds'] = $money['refunds']; if ( $unpaid ) { $record['unpaid'] = true; } else { unset( $record['unpaid'] ); } $order->update_meta_data( '_improveab_credited', $record, (int) $meta->id ); ImproveAB_Tests::forget_results( (int) $record['test'] ); $changed = true; } return $changed; } /** * Whether an order holds anything of this plugin's, or may be about to, * so that the hooks every order of a store passes take its lock only * then. The order as loaded answers first, for nothing; a store with no * running purchase-goal test stops there, since no beacon can be keeping * a place on the order (/event takes a conversion for a running test * only). Else the order's lock row is read, then its meta straight from * its table: a request holding the lock is about to keep a place or take * a credit; one that let it go before has written what the read finds; * one that takes it after loads the order as this change left it (paid, * refunded), and credits it itself. * * @param WC_Order $order Order. * @return bool */ private static function touched( $order ) { $keys = array( '_improveab_pending', '_improveab_credited' ); foreach ( $keys as $key ) { if ( $order->get_meta( $key, false ) ) { return true; } } $front = ImproveAB_Tests::front(); if ( empty( $front['buy'] ) ) { return false; } global $wpdb; // An atomic claim's row: the options cache must not answer. if ( null !== $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'improveab_vs_order_' . (int) $order->get_id() ) ) ) { return true; } foreach ( (array) $order->get_data_store()->read_meta( $order ) as $row ) { if ( is_object( $row ) && isset( $row->meta_key ) && in_array( $row->meta_key, $keys, true ) ) { return true; } } return false; } /** * Keep a visitor's place on an order not paid yet, one per test: the * test, the visitor, the version and device the conversion is to be * written under, and the page. Nothing when the order keeps a place for * the test already (one order is one purchase) or the visitor has * converted already (one conversion per visitor per test). Called * holding the order's lock. * * @param WC_Order $order Order. * @param int $test_id Test ID. * @param string $vid Visitor id. * @param string $variant Variant key (checked against the impression). * @param string $segment Device class or ''. * @param string $url Page path. */ private static function keep_place( $order, $test_id, $vid, $variant, $segment, $url ) { global $wpdb; foreach ( self::places( $order ) as $p ) { if ( $p['place'] && $p['place']['test'] === (int) $test_id ) { return; } } if ( $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND visitor_token = %s AND event_type = 'conversion'", $test_id, $vid ) ) ) { return; } $order->add_meta_data( '_improveab_pending', array( 'test' => (int) $test_id, 'vid' => (string) $vid, 'variant' => (string) $variant, 'segment' => (string) $segment, 'url' => (string) $url, ) ); $order->save_meta_data(); } /** * The places an order keeps, read again from the database (another * request may have written one a moment ago): a list of { mid, place }, * the place null when the row is not one. * * @param WC_Order $order Order. * @return array */ private static function places( $order ) { $order->read_meta_data( true ); $out = array(); foreach ( (array) $order->get_meta( '_improveab_pending', false ) as $meta ) { $data = is_object( $meta ) && isset( $meta->value ) ? $meta->value : null; $place = null; if ( is_array( $data ) && isset( $data['test'], $data['vid'] ) && is_string( $data['vid'] ) ) { $place = array( 'test' => (int) $data['test'], 'vid' => substr( preg_replace( '/[^a-zA-Z0-9\-]/', '', $data['vid'] ), 0, 64 ), 'variant' => isset( $data['variant'] ) && is_string( $data['variant'] ) ? $data['variant'] : '', 'segment' => isset( $data['segment'] ) && in_array( $data['segment'], array( 'desktop', 'mobile', 'tablet' ), true ) ? $data['segment'] : '', 'url' => isset( $data['url'] ) && is_string( $data['url'] ) ? substr( $data['url'], 0, 255 ) : '', ); } $out[] = array( 'mid' => is_object( $meta ) && isset( $meta->id ) ? (int) $meta->id : 0, 'place' => $place && $place['test'] > 0 && '' !== $place['vid'] ? $place : null, ); } return $out; } /** * Write the conversion a paid order's place waited for, as /event writes * one: under the version of the visitor's impression (the place's own), * the database's clock, de-duplicated by the unique key. Fires * `improveab_event_recorded` like a beacon's event. * * @param int $test_id Test ID. * @param string $vid Visitor id. * @param string $variant Variant key the place names ('' for the impression's). * @param string $segment Device class or ''. * @param float $value Revenue. * @param string $url Page path. * @return array|null|false { variant, segment, at } of the row written; * null when there is nothing to write (the * visitor converted already, has no impression * on record, or the test is gone); false when * the database refused a statement, so the * place is kept for another try. */ private static function write_conversion( $test_id, $vid, $variant, $segment, $value, $url ) { global $wpdb; // A deleted test's rows still being cleared are no test to count in. $live = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}improveab_tests WHERE id = %d AND status <> 'draft'", $test_id ) ); if ( '' !== $wpdb->last_error ) { return false; } if ( ! $live ) { return null; } $seen = $wpdb->get_var( $wpdb->prepare( "SELECT variant_key FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND visitor_token = %s AND event_type = 'impression'", $test_id, $vid ) ); if ( '' !== $wpdb->last_error ) { return false; } if ( null === $seen || ( '' !== $variant && (string) $seen !== $variant ) ) { return null; } $variant = (string) $seen; $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}improveab_events (test_id, variant_key, event_type, visitor_token, segment, value, url, created_at) VALUES (%d, %s, 'conversion', %s, %s, %f, %s, UTC_TIMESTAMP())", $test_id, $variant, $vid, $segment, $value, $url ) ); if ( '' !== $wpdb->last_error ) { return false; } if ( 1 !== (int) $wpdb->rows_affected ) { return null; } $at = (string) $wpdb->get_var( $wpdb->prepare( "SELECT created_at FROM {$wpdb->prefix}improveab_events WHERE id = %d", (int) $wpdb->insert_id ) ); /** This action is documented in record_event(). */ do_action( 'improveab_event_recorded', (int) $test_id, array( 'variant' => $variant, 'type' => 'conversion', 'token' => $vid, 'segment' => $segment, 'value' => $value, 'url' => $url, 'handoff' => false, ), true ); return array( 'variant' => $variant, 'segment' => $segment, 'at' => $at, ); } /** * What an order notes of a conversion credited from it (order meta * `_improveab_credited`, a row of its own each): the test, the visitor, * the version and device, when it was written (UTC) and the revenue it * carries now, with the refunds already taken off it, so a refund finds * the conversion again, also once the rollup has compacted it into its * day. * * @param int $test_id Test ID. * @param string $vid Visitor id. * @param string $variant Variant key. * @param string $segment Device class or ''. * @param string $at The row's created_at. * @param float $value Revenue. * @param int[] $refunds The order's refunds already taken off it (order_money()). * @return array */ private static function credit_record( $test_id, $vid, $variant, $segment, $at, $value, $refunds ) { return array( 'test' => (int) $test_id, 'vid' => (string) $vid, 'variant' => (string) $variant, 'segment' => (string) $segment, 'at' => (string) $at, 'value' => round( (float) $value, 2 ), 'refunds' => array_values( array_map( 'intval', (array) $refunds ) ), ); } /** * Claim a key (claim()), waiting a moment for a request that holds it * to finish: an order's lock is held for milliseconds. A call made * while this request holds the key already (a hook WooCommerce fires * inside one of these) goes on rather than wait for itself; let_go() * releases the key when the outermost holder is done. * * @param string $key Key. * @param float $seconds How long to wait at most. * @return bool */ private static function claim_wait( $key, $seconds = 3.0 ) { if ( ! empty( self::$held[ $key ] ) ) { ++self::$held[ $key ]; return true; } $until = microtime( true ) + $seconds; while ( ! self::claim( $key ) ) { if ( microtime( true ) >= $until ) { return false; } usleep( 50000 ); } self::$held[ $key ] = 1; return true; } /** * Release a key taken with claim_wait(), once its outermost holder in * this request is done ('' is no key). * * @param string $key Key. */ private static function let_go( $key ) { if ( '' === $key || empty( self::$held[ $key ] ) ) { return; } --self::$held[ $key ]; if ( 0 === self::$held[ $key ] ) { unset( self::$held[ $key ] ); self::release( $key ); } } /** * Where this request comes from, by what the browser says: '' for this * site's own pages (and for a request that names no origin: an older * browser, a server), else the sending page's host, or 'cross-site' when * the browser only says the request crosses sites. A page whose policy * sends no referrer names its origin "null": then only Sec-Fetch-Site * tells, and it says same-origin or same-site for this site's own. (A * handoff beacon from the far domain names its origin whatever that * page's policy: the runtime sends it as a CORS fetch.) * * @return string */ private function foreign_origin() { $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) ) ) : ''; $fetch = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) ) ) : ''; if ( '' !== $origin && 'null' !== $origin ) { // In the form a browser names hosts (an international name as xn--): // the site's own hosts are held as typed. $host = improveab_ascii_host( (string) wp_parse_url( $origin, PHP_URL_HOST ) ); if ( '' !== $host && improveab_host_within( $host, improveab_trusted_hosts( true ) ) ) { return ''; } return '' !== $host ? $host : 'cross-site'; } return 'cross-site' === $fetch ? 'cross-site' : ''; } /** * Whether the browser says the sending page is this site's own: its * Origin names the home or site address's host ("www." aside, so a link * on the www twin of the address is no more the far domain than the * address itself; any other subdomain is another host, which a variant on * this domain may really be), or Sec-Fetch-Site says same-origin, which * is all a page under a no-referrer policy (Origin: null) tells. * * @return bool */ private static function from_own_site() { $fetch = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) ) ) : ''; if ( 'same-origin' === $fetch ) { return true; } $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? strtolower( trim( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) ) ) : ''; if ( '' === $origin || 'null' === $origin ) { return false; } $plain = static function ( $url ) { return (string) preg_replace( '/^www\./', '', improveab_ascii_host( (string) wp_parse_url( (string) $url, PHP_URL_HOST ) ) ); }; $from = $plain( $origin ); return '' !== $from && in_array( $from, array( $plain( home_url() ), $plain( site_url() ) ), true ); } /** * The tests an order's revenue has already been credited to: every * `_improveab_credited` row on the order, each a credit record * (credit_record()) or, written before the conversion was named on it, * a list of test ids. * * @param WC_Order $order Order. * @return int[] */ private static function credited_tests( $order ) { $ids = array(); foreach ( (array) $order->get_meta( '_improveab_credited', false ) as $row ) { $value = is_object( $row ) && isset( $row->value ) ? $row->value : $row; if ( is_array( $value ) && isset( $value['test'] ) ) { $ids[] = (int) $value['test']; continue; } foreach ( (array) $value as $id ) { $ids[] = (int) $id; } } return array_values( array_unique( array_filter( $ids ) ) ); } /** * Claim a short-lived key atomically: true for exactly one of any number * of simultaneous callers. The database decides, with the same INSERT * IGNORE against the options table's unique name that guards the upgrade * (add_option() pre-checks with get_option() and then upserts, so two * requests can both "win" it), with or without a persistent object * cache: a cache's add is atomic only if its drop-in makes it so (the * SQLite Object Cache's is a read, then a write, so two beacons of one * order could both take its revenue), and an order's lock guards money. A * claim nobody released, from a request that died holding it, is taken * over after a minute. * * @param string $key Key. * @return bool */ private static function claim( $key ) { global $wpdb; // An atomic claim: the options cache must not answer any of these. $now = (string) time(); $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $key, $now ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( 1 === (int) $wpdb->rows_affected ) { return true; } $held = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( '' === $held || time() - (int) $held < MINUTE_IN_SECONDS ) { return false; } // Taken over only if nobody else did in the meantime. $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $now, $key, $held ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return 1 === (int) $wpdb->rows_affected; } /** * Release a key taken with claim(). * * @param string $key Key. */ private static function release( $key ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the row claim() wrote. $wpdb->delete( $wpdb->options, array( 'option_name' => $key ), array( '%s' ) ); } /** * Basic user-agent bot detection. * * @return bool */ private function is_bot() { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua ) { return true; } // Note: search/social crawlers such as Bingbot, BingPreview, // DuckDuckBot, Pinterestbot, Quora Link Preview, Baiduspider and // YandexBot are all matched by the generic "bot" / "spider" / // "preview" tokens. Their bare brand names are deliberately NOT // listed, because those substrings also appear in real consumer // browsers (the Bing app, the DuckDuckGo browser, Pinterest / Quora // in-app webviews, the Baidu app, the Yandex app) whose visitors // must be counted. return (bool) preg_match( '/bot|crawl|spider|slurp|facebookexternalhit|embedly|headless|phantom|puppeteer|playwright|lighthouse|preview|monitor|pingdom|gtmetrix|ahrefs|semrush/i', $ua ); } /** * Resolve the client IP used for rate limiting. * * REMOTE_ADDR, except when that is a loopback or private address (a * reverse proxy on the same host or LAN): then the nearest hop of * X-Forwarded-For is used. Forwarded headers are otherwise spoofable and * NOT trusted. Sites behind a CDN (where REMOTE_ADDR is a public CDN * address shared by every visitor) can return the real client IP via * this filter, e.g. from HTTP_CF_CONNECTING_IP. * * @return string */ private function client_ip() { // An IPv4 visitor that a dual-stack socket shows as ::ffff:a.b.c.d is // judged, matched against Cloudflare's ranges and counted as the IPv4 // address it wraps (unmap_ip()). $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? self::unmap_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) ) : '0'; // Behind a reverse proxy on the same host or LAN, REMOTE_ADDR is the // proxy for every visitor and the whole site would share one budget. // Only then - a loopback or private address, which no real visitor can // arrive from - trust the nearest hop of X-Forwarded-For, the one our // own proxy appended. A public REMOTE_ADDR is used as is: the header // itself is freely spoofable. if ( false !== filter_var( $ip, FILTER_VALIDATE_IP ) && false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) && ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $hops = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ); $last = trim( (string) end( $hops ) ); if ( false !== filter_var( $last, FILTER_VALIDATE_IP ) ) { $ip = self::unmap_ip( $last ); } } // Behind Cloudflare without IP restoration at the web server, every // visitor arrives from a Cloudflare edge address and the whole site // would share a few budgets. Cloudflare sets CF-Connecting-IP itself // and overwrites any copy a client sent, so the header is trustworthy // exactly when REMOTE_ADDR is one of Cloudflare's published ranges. if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && self::is_cloudflare_ip( $ip ) ) { $cf = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ); if ( false !== filter_var( $cf, FILTER_VALIDATE_IP ) ) { $ip = self::unmap_ip( $cf ); } } /** * Filter the client IP used for per-IP rate limiting. * * @param string $ip The address resolved above: REMOTE_ADDR; behind a * reverse proxy on a loopback or private address, * the last X-Forwarded-For hop, the one that proxy * appended; behind Cloudflare, CF-Connecting-IP. * An IPv4 client that the server reports as * ::ffff:a.b.c.d arrives as the dotted IPv4 address. * A proxy on a private address that passes the * header through without appending to it (a plain * TCP forwarder) lets visitors choose that hop: * restore the real address at the web server, or * return it here. */ return (string) apply_filters( 'improveab_client_ip', $ip ); } /** * The IPv4 address an IPv4-mapped IPv6 address (::ffff:a.b.c.d, in any * spelling: capitals, the hex form, zeros written out) wraps; any other * address as it came. A dual-stack socket reports an IPv4 visitor this * way, and PHP's private-or-reserved test reads the block differently * from one version to the next (7.4 calls a mapped private address * public; 8.3 and later call the whole ::ffff:0:0/96 block reserved), so * a mapped public visitor was a "proxy" whose X-Forwarded-For is * believed, and a mapped Cloudflare edge was no edge. Unmapped first, the * answer is the same on every PHP and for every spelling. * * @param string $ip Address as the server reports it. * @return string */ private static function unmap_ip( $ip ) { $ip = (string) $ip; if ( false === strpos( $ip, ':' ) || false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { return $ip; } $bin = inet_pton( $ip ); if ( false === $bin || 16 !== strlen( $bin ) || 0 !== strpos( $bin, str_repeat( "\0", 10 ) . "\xff\xff" ) ) { return $ip; } $v4 = inet_ntop( substr( $bin, 12 ) ); return false !== $v4 ? $v4 : $ip; } /** * Whether an address belongs to Cloudflare's published proxy ranges * (https://www.cloudflare.com/ips/). The list changes rarely; a range * missing here only means that edge shares one budget like any proxy. * * @param string $ip Address to test. * @return bool */ private static function is_cloudflare_ip( $ip ) { $ranges = array( '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32', ); // An invalid address reads as "not Cloudflare"; validated first, so // inet_pton() never warns. if ( false === filter_var( $ip, FILTER_VALIDATE_IP ) ) { return false; } $bin = inet_pton( $ip ); if ( false === $bin ) { return false; } foreach ( $ranges as $range ) { list( $net, $bits ) = explode( '/', $range ); $nbin = inet_pton( $net ); if ( false === $nbin || strlen( $nbin ) !== strlen( $bin ) ) { continue; } $bits = (int) $bits; $bytes = intdiv( $bits, 8 ); $rem = $bits % 8; if ( 0 !== substr_compare( $bin, $nbin, 0, $bytes ) ) { continue; } if ( 0 === $rem || ( ( ord( $bin[ $bytes ] ) ^ ord( $nbin[ $bytes ] ) ) & ( 0xFF << ( 8 - $rem ) ) & 0xFF ) === 0 ) { return true; } } return false; } /** * Per-IP rate limiting over the clock minute. The address is keyed by a * salted hash and never stored. * * The default ceiling is generous because visitor de-duplication already * caps each visitor to one impression and one conversion per test; this * limit only exists to blunt a single source firing at many tests or tokens. * Both the limit and the resolved IP are filterable so high-traffic or * proxied sites are not throttled to a single shared address. * * @param int $count Events this request carries (an envelope counts each). * @return bool True if the request is within the limit. */ private function within_rate_limit( $count = 1 ) { /** * Filter the maximum number of events allowed per window per client IP. * * @param int $limit Default RATE_LIMIT. */ $limit = (int) apply_filters( 'improveab_rate_limit', self::RATE_LIMIT ); if ( $limit <= 0 ) { return true; // A non-positive limit disables throttling. } $count = max( 1, (int) $count ); $key = self::rate_key( $this->client_ip() ); $minute = (int) floor( time() / self::RATE_WINDOW ); // Always the one statement on the plugin's own table, also on a site // with a persistent object cache: a cache's increment is atomic only // if its drop-in makes it so (the SQLite Object Cache's is a read and // a write, and on a lock it deletes the whole cache and ends the // request with a 500), so a beacon must never depend on one. $n = self::count_events( $key, $minute, $count ); // A counter that could not be written (the table not there yet, in // the moment after an update) lets the events through. return null === $n || $n <= $limit; } /** * Raise an address's counter for this minute by $count and return it: * one row per address in the plugin's own table, started again at each * new minute, in one statement the database applies atomically. The * new count comes back through LAST_INSERT_ID(expr), which the statement * sets on an update (on an insert the row holds $count). The SQLite * drop-in has no LAST_INSERT_ID(expr), so there the row is read back. * * @param int $key Address key (rate_key()). * @param int $minute Clock minute. * @param int $count Events to add. * @return int|null The minute's count, or null when it could not be kept. */ private static function count_events( $key, $minute, $count ) { global $wpdb; $sqlite = defined( 'DB_ENGINE' ) && 'sqlite' === DB_ENGINE; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic counter in the plugin's own table; caching it would defeat it. $suppress = $wpdb->suppress_errors( true ); if ( $sqlite ) { $done = $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}improveab_rl (k, m, n) VALUES (%d, %d, %d) ON DUPLICATE KEY UPDATE n = CASE WHEN m = %d THEN n + %d ELSE %d END, m = %d", $key, $minute, $count, $minute, $count, $count, $minute ) ); } else { $done = $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}improveab_rl (k, m, n) VALUES (%d, %d, %d) ON DUPLICATE KEY UPDATE n = LAST_INSERT_ID(IF(m = %d, n + %d, %d)), m = %d", $key, $minute, $count, $minute, $count, $count, $minute ) ); } $wpdb->suppress_errors( $suppress ); if ( false === $done ) { return null; } if ( $sqlite ) { $n = $wpdb->get_var( $wpdb->prepare( "SELECT n FROM {$wpdb->prefix}improveab_rl WHERE k = %d", $key ) ); return null === $n ? null : (int) $n; } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return 1 === (int) $wpdb->rows_affected ? $count : (int) $wpdb->insert_id; } /** * Drop the rate-limit counters of past minutes (the nightly rollup calls * it): an address that never sends again leaves its last row. Every one * of them, however many addresses the day saw, until the deadline (a * fixed number of batches left the rest to pile up night after night): * in batches read first and deleted by key, so a delete never walks the * table holding the locks /event's counters wait on. With $all, the * table is emptied in one statement (deactivation, as uninstall does: no * rollup runs after it). * * @param bool $all Every counter, the current minute's too. * @param int $deadline Unix time to stop at (0: twenty seconds from now). * @return int Rows removed. */ public static function sweep_rate_counters( $all = false, $deadline = 0 ) { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own counter rows; the keys are bound one %d each in the IN() list built from their count. $suppress = $wpdb->suppress_errors( true ); if ( $all ) { $gone = (int) $wpdb->query( "DELETE FROM {$wpdb->prefix}improveab_rl" ); $wpdb->suppress_errors( $suppress ); return $gone; } $before = (int) floor( time() / self::RATE_WINDOW ) - 1; $deadline = (int) $deadline > 0 ? (int) $deadline : time() + 20; $gone = 0; while ( time() < $deadline ) { $keys = $wpdb->get_col( $wpdb->prepare( "SELECT k FROM {$wpdb->prefix}improveab_rl WHERE m < %d LIMIT 1000", $before ) ); if ( empty( $keys ) || '' !== $wpdb->last_error ) { break; } $n = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}improveab_rl WHERE k IN (" . implode( ',', array_fill( 0, count( $keys ), '%d' ) ) . ')', array_map( 'absint', $keys ) ) ); if ( false === $n ) { break; } $gone += (int) $n; } $wpdb->suppress_errors( $suppress ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $gone; } /** * The rate-limit key for an address: an IPv6 address counts by its /64 * (one subscriber's whole allocation, so rotating addresses inside it * does not buy a fresh budget), and the hash is keyed with this site's * salt, so the stored key cannot be turned back into an address. The * first 60 bits of the HMAC, an integer every database and PHP holds. * * @param string $ip Client address. * @return int */ public static function rate_key( $ip ) { // An invalid address is keyed as its text; a mapped one as the IPv4 // address it wraps. $ip = self::unmap_ip( $ip ); $bin = false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? inet_pton( $ip ) : false; if ( false !== $bin && 16 === strlen( $bin ) ) { $ip = inet_ntop( substr( $bin, 0, 8 ) . str_repeat( "\0", 8 ) ) . '/64'; } return (int) hexdec( substr( hash_hmac( 'sha256', (string) $ip, wp_salt( 'nonce' ) ), 0, 15 ) ); } /* --------------------------------------------------------------------- */ /* Admin */ /* --------------------------------------------------------------------- */ /** * List tests. `?view=summary` leaves out what only a test's own page * shows (description, notes, meta, each variant's edits): the tests * list asks for that. * * @param WP_REST_Request|null $request Request. * @return WP_REST_Response */ public function list_tests( $request = null ) { $summary = $request instanceof WP_REST_Request && 'summary' === $request->get_param( 'view' ); return new WP_REST_Response( array( 'tests' => $this->attach_totals( ImproveAB_Tests::all( $summary ) ) ), 200 ); } /** * Attach lifetime impressions / conversions to a list of tests: the * tally, the events past it and the rolled-up days, one statement * however many tests exist. * * @param array $tests Tests from ImproveAB_Tests::all(). * @return array */ private function attach_totals( $tests ) { if ( empty( $tests ) ) { return $tests; } $totals = ImproveAB_Tests::totals(); $moved = ImproveAB_Tests::moved( $tests ); foreach ( $tests as &$t ) { $tid = (int) $t['id']; $t['impressions'] = isset( $totals[ $tid ] ) ? $totals[ $tid ][0] : 0; $t['conversions'] = isset( $totals[ $tid ] ) ? $totals[ $tid ][1] : 0; // The list's red "Split off" tag: a broken split must be findable // without opening the test. $t['split_off'] = (bool) ImproveAB_Stats::split_off( $t ); // A running URL test's addresses that no longer name their pages // (a permalink change): it splits nobody until it is fixed. $t['moved'] = isset( $moved[ $tid ] ) ? $moved[ $tid ] : array(); } unset( $t ); return $tests; } /** * Create a test. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ /** * Add the sanitizer's "variants dropped" notice to a create-style response. * * @param array $body Response body. * @return array */ private function with_trim_notice( $body ) { $notice = ImproveAB_Tests::trim_notice(); if ( '' !== $notice ) { $body['notice'] = $notice; } return $body; } public function create_test( $request ) { $data = $request->get_json_params(); $test = ImproveAB_Tests::create( is_array( $data ) ? $data : array() ); if ( is_wp_error( $test ) ) { return $test; } return new WP_REST_Response( $this->with_trim_notice( array( 'test' => $test ) ), 201 ); } /** * Get a single test. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_test( $request ) { $test = ImproveAB_Tests::get( absint( $request['id'] ) ); if ( ! $test ) { return new WP_Error( 'improveab_not_found', ImproveAB_Tests::gone_text(), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'test' => $test ), 200 ); } /** * Update a test. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_test( $request ) { $data = $request->get_json_params(); $test = ImproveAB_Tests::update( absint( $request['id'] ), is_array( $data ) ? $data : array() ); if ( is_wp_error( $test ) ) { return $test; } return new WP_REST_Response( array( 'test' => $test ), 200 ); } /** * Delete a test. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function delete_test( $request ) { ImproveAB_Tests::delete( absint( $request['id'] ) ); return new WP_REST_Response( array( 'deleted' => true ), 200 ); } /** * Start, stop, or declare a winner. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function set_status( $request ) { $data = $request->get_json_params(); $action = isset( $data['action'] ) ? sanitize_text_field( $data['action'] ) : ''; $winner = isset( $data['winner'] ) ? sanitize_text_field( $data['winner'] ) : ''; $test = ImproveAB_Tests::set_status( absint( $request['id'] ), $action, $winner ); if ( is_wp_error( $test ) ) { return $test; } return new WP_REST_Response( array( 'test' => $test ), 200 ); } /** * Return analyzed results for a test. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_results( $request ) { $id = absint( $request['id'] ); $test = ImproveAB_Tests::get( $id ); if ( ! $test ) { return new WP_Error( 'improveab_not_found', ImproveAB_Tests::gone_text(), array( 'status' => 404 ) ); } $segment = $request->get_param( 'segment' ); $segment = $segment ? sanitize_text_field( $segment ) : ''; $results = ImproveAB_Tests::results( $id, $segment ); $analysis = ImproveAB_Stats::analyze( $test, $results, $segment ); $analysis = $this->with_money_text( $analysis ); $test['moved'] = ImproveAB_Tests::moved_addresses( $test ); return new WP_REST_Response( array( 'test' => $test, 'analysis' => $analysis, 'segment' => $segment, ), 200 ); } /** * Money in the store's own format: WooCommerce's currency, symbol, * position, separators and decimals (wc_price()), as plain text beside * each number, so the results page never shows revenue without its * currency. Without WooCommerce the numbers stand alone. * * @param array $analysis The analysis for the results page. * @return array */ private function with_money_text( $analysis ) { if ( ! function_exists( 'wc_price' ) || ! is_array( $analysis ) ) { return $analysis; } $text = static function ( $amount ) { return trim( html_entity_decode( wp_strip_all_tags( wc_price( (float) $amount ) ), ENT_QUOTES, 'UTF-8' ) ); }; if ( isset( $analysis['revenue'] ) ) { $analysis['revenue_text'] = $text( $analysis['revenue'] ); } if ( ! empty( $analysis['variants'] ) && is_array( $analysis['variants'] ) ) { foreach ( $analysis['variants'] as &$v ) { if ( isset( $v['revenue'] ) ) { $v['revenue_text'] = $text( $v['revenue'] ); } if ( isset( $v['rpv'] ) ) { $v['rpv_text'] = $text( $v['rpv'] ); } } unset( $v ); } return $analysis; } /** * Email a plain-text summary of a test's current results to the * requesting user's own address. An administrator may name another one * in `to` (on a network, an address of this site's own users unless * they manage the network). `segment` names the device the results page * shows (desktop, mobile or tablet), so the email carries the same * numbers as Download summary beside it; without one, every device. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function email_summary( $request ) { $id = absint( $request['id'] ); $test = ImproveAB_Tests::get( $id ); if ( ! $test ) { return new WP_Error( 'improveab_not_found', ImproveAB_Tests::gone_text(), array( 'status' => 404 ) ); } $body = $request->get_json_params(); // The device the page is on: one of the three device classes, or // none for every device. Anything else is refused before it counts // against the hourly allowance. $segment = ( is_array( $body ) && isset( $body['segment'] ) ) ? $body['segment'] : ''; if ( '' !== $segment ) { $segment = is_scalar( $segment ) ? ImproveAB_Tests::device_segment( $segment ) : null; } if ( null === $segment ) { return new WP_Error( 'improveab_segment', __( 'Unknown device. Choose desktop, mobile or tablet, or none for every device.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } $user = wp_get_current_user(); $own = ( $user && $user->user_email ) ? (string) $user->user_email : ''; $to = ( is_array( $body ) && ! empty( $body['to'] ) ) ? sanitize_email( ImproveAB_Tests::as_string( $body['to'] ) ) : ''; if ( '' === $to || ! is_email( $to ) ) { $to = '' !== $own ? $own : (string) get_option( 'admin_email' ); } // The text is the plugin's, but the address and the moment are the // caller's: anyone else who may manage tests can only mail // themselves, so the site never becomes a relay for mail under its // own name. if ( ! $this->may_mail( $to, $own ) ) { return new WP_Error( 'improveab_bad_recipient', __( 'A summary goes to your own email address. Only an administrator can send it to another one.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } $limited = $this->summary_limit(); if ( is_wp_error( $limited ) ) { return $limited; } $text = $this->summary_text( $test, $segment ); /* translators: %s: test name. */ $subject = sprintf( __( '[A/B test summary] %s', 'improveab-ab-testing' ), $test['name'] ); // The plugin only hands the message to wp_mail(); whether it leaves // the server is the site's own email setup (an SMTP plugin, the // host's mail service). When that refuses, the raw PHPMailer text // ("Could not instantiate mail function", "Invalid address: (From)") // means nothing to most site owners, so it is wrapped in a message // that says where to look, and the raw detail is kept alongside. $why = ''; $note = function ( $error ) use ( &$why ) { $why = is_wp_error( $error ) ? trim( (string) $error->get_error_message() ) : ''; }; add_action( 'wp_mail_failed', $note ); $sent = (bool) wp_mail( $to, $subject, $text ); remove_action( 'wp_mail_failed', $note ); $detail = ''; $error = ''; if ( ! $sent ) { /* translators: %s: the raw error reported by the site's mailer. */ $detail = '' !== $why ? sprintf( __( 'The mailer reported: %s', 'improveab-ab-testing' ), $why ) : ''; $error = __( 'Your site\'s email setup did not send it. If password reset emails from this site do not arrive either, set up an SMTP plugin or ask your host.', 'improveab-ab-testing' ); if ( '' !== $detail ) { $error .= ' ' . $detail; } } return new WP_REST_Response( array( 'sent' => $sent, 'to' => $to, // Accepted by wp_mail() is not the same as delivered: a mailer // can accept a message the receiving side then drops. 'note' => $sent ? __( 'Handed to your site\'s mailer. If it does not arrive, check your site\'s email setup.', 'improveab-ab-testing' ) : '', 'error' => $error, 'detail' => $detail, ), 200 ); } /** * Whether the current user may send a summary to this address: their * own always; an administrator any address on a single site, and on a * network one of this site's users or its admin email, unless they * manage the network (the users table holds every site's users). * * @param string $to Recipient. * @param string $own The current user's own address. * @return bool */ private function may_mail( $to, $own ) { if ( '' !== $own && strtolower( $to ) === strtolower( $own ) ) { return true; } if ( ! current_user_can( 'manage_options' ) ) { return false; } if ( ! is_multisite() || is_super_admin() ) { return true; } if ( strtolower( $to ) === strtolower( (string) get_option( 'admin_email' ) ) ) { return true; } $recipient = get_user_by( 'email', $to ); return $recipient && is_user_member_of_blog( $recipient->ID ); } /** * Count this summary against the user's hourly allowance (ten an hour, * `improveab_summary_email_limit`; 0 or less lifts it), or say when the * next one may go. Every attempt counts, sent or refused by the mailer. * * @return true|WP_Error */ private function summary_limit() { /** * How many summaries one user may email in an hour. * * @param int $limit Default 10. 0 or less: no limit. */ $limit = (int) apply_filters( 'improveab_summary_email_limit', 10 ); if ( $limit <= 0 ) { return true; } $key = 'improveab_mail_' . get_current_user_id(); $now = time(); $times = get_transient( $key ); $times = array_values( array_filter( is_array( $times ) ? $times : array(), static function ( $t ) use ( $now ) { return is_int( $t ) && $t > $now - HOUR_IN_SECONDS && $t <= $now; } ) ); if ( count( $times ) >= $limit ) { sort( $times ); $wait = max( 1, (int) ceil( ( $times[ count( $times ) - $limit ] + HOUR_IN_SECONDS - $now ) / MINUTE_IN_SECONDS ) ); return new WP_Error( 'improveab_summary_limit', sprintf( /* translators: 1: summaries one person may email in an hour, 2: minutes until the next one may go. */ _n( 'You have emailed %1$d summaries in the last hour, the most allowed. Try again in %2$d minute.', 'You have emailed %1$d summaries in the last hour, the most allowed. Try again in %2$d minutes.', $wait, 'improveab-ab-testing' ), $limit, $wait ), array( 'status' => 429 ) ); } $times[] = $now; set_transient( $key, $times, HOUR_IN_SECONDS ); return true; } /** * The result in words, as the results page's hero titles it (admin.js * heroCopy, srmTitle and deviceCopy): the traffic split warning first, * naming the version that falls short; on one device, that a device is * a hint, not a verdict; then the data still coming in, the version * ahead, or no winner. It names the version with the highest rate, so * far or when the test stopped, and promises no significance. A paused or stopped test is not collecting, and a * stopped one has no "yet". * * @param array $test Hydrated test. * @param array $analysis The analysis for the results page. * @param string $segment The device the numbers cover, '' for every device. * @return string */ private function result_words( $test, $analysis, $segment = '' ) { $verdict = isset( $analysis['verdict'] ) && is_array( $analysis['verdict'] ) ? $analysis['verdict'] : array(); $state = isset( $verdict['state'] ) ? (string) $verdict['state'] : ''; if ( 'srm' === $state || ! empty( $verdict['srm'] ) ) { $short = $this->split_short( $test, $analysis ); if ( 1 === count( $short ) ) { /* translators: %s: a variant's name, e.g. Variant B. */ return sprintf( __( '%s is getting far fewer visitors than planned.', 'improveab-ab-testing' ), $short[0] ); } return $short ? __( 'Some variants are getting far fewer visitors than planned.', 'improveab-ab-testing' ) : __( 'The visitors are not splitting as planned.', 'improveab-ab-testing' ); } // One device's numbers are never read as a result: a device swings // more than all of them, and one of three looks shows a gap by luck // more often than one look does. if ( '' !== $segment ) { /* translators: %s: a device class, e.g. Mobile. */ return sprintf( __( '%s alone is a hint, not a verdict.', 'improveab-ab-testing' ), $this->device_name( $segment ) ); } if ( 'collecting' === $state ) { return 'running' === $test['status'] ? __( 'Collecting data.', 'improveab-ab-testing' ) : __( 'Not enough data yet.', 'improveab-ab-testing' ); } $stopped = 'stopped' === $test['status']; // Without a test of significance, the version with the highest rate // is named as just that, stopped or not (admin.js heroCopy()). $lead = $this->leader( $test, $analysis ); if ( $stopped ) { /* translators: %s: a variant's name, e.g. Variant B. */ return '' !== $lead ? sprintf( __( '%s led when the test stopped.', 'improveab-ab-testing' ), $lead ) : __( 'No winner was declared.', 'improveab-ab-testing' ); } /* translators: %s: a variant's name, e.g. Variant B. */ return '' !== $lead ? sprintf( __( '%s leads so far.', 'improveab-ab-testing' ), $lead ) : __( 'No variant leads yet.', 'improveab-ab-testing' ); } /** * The version the results page marks Leading (admin.js resultsCtx): the * highest rate once past the minimum, none while another version shows * the same rate, and none before a visitor. Its name, or ''. * * @param array $test Hydrated test. * @param array $analysis The analysis for the results page. * @return string */ private function leader( $test, $analysis ) { $best = isset( $analysis['verdict']['best'] ) ? (string) $analysis['verdict']['best'] : ''; $variants = isset( $analysis['variants'] ) ? array_values( (array) $analysis['variants'] ) : array(); $top = null; foreach ( $variants as $v ) { if ( isset( $v['key'] ) && $best === (string) $v['key'] ) { $top = $v; } } if ( null === $top || (int) $top['impressions'] <= 0 ) { return ''; } foreach ( $variants as $v ) { // The rates as shown, two decimals from the counts: level there is // level on the page. if ( $best !== (string) $v['key'] && (int) $v['impressions'] > 0 && (float) $v['rate'] === (float) $top['rate'] ) { return ''; } } $found = ImproveAB_Tests::variant_by_key( $test, $best ); return $found ? $found[1] : ''; } /** * A device class as the results page names it. * * @param string $segment desktop, mobile or tablet. * @return string */ private function device_name( $segment ) { $names = array( 'desktop' => __( 'Desktop', 'improveab-ab-testing' ), 'mobile' => __( 'Mobile', 'improveab-ab-testing' ), 'tablet' => __( 'Tablet', 'improveab-ab-testing' ), ); return isset( $names[ $segment ] ) ? $names[ $segment ] : (string) $segment; } /** * The names of the versions far short of their planned share, by the * results page's rule (admin.js splitFacts): more than three standard * errors under the visitors its planned share expects. * * @param array $test Hydrated test. * @param array $analysis The analysis for the results page. * @return string[] */ private function split_short( $test, $analysis ) { $rows = isset( $analysis['split']['variants'] ) && is_array( $analysis['split']['variants'] ) ? $analysis['split']['variants'] : array(); $variants = array_values( (array) $analysis['variants'] ); // Every version is planned the same share. $w = array_fill( 0, count( $variants ), 1 ); $seen = array(); foreach ( $variants as $i => $v ) { $seen[ $i ] = isset( $rows[ $v['key'] ]['seen'] ) ? (int) $rows[ $v['key'] ]['seen'] : (int) $v['impressions']; } $total_w = array_sum( $w ); $total_w = $total_w > 0 ? $total_w : 1; $short = array(); foreach ( $variants as $i => $v ) { $expected = array_sum( $seen ) * $w[ $i ] / $total_w; if ( $expected > 0 && ( $expected - $seen[ $i ] ) / sqrt( $expected ) > 3 ) { $short[] = ImproveAB_Tests::display_label( isset( $v['label'] ) ? $v['label'] : $v['key'], $i ); } } return $short; } /** * Build the plain-text summary used by the email (and mirrored by the * client-side download). Uses exactly the same analysis as the results * screen, on the same device, so numbers always agree. * * @param array $test Hydrated test. * @param string $segment The device the numbers cover, '' for every device. * @return string */ private function summary_text( $test, $segment = '' ) { $results = ImproveAB_Tests::results( (int) $test['id'], $segment ); $analysis = $this->with_money_text( ImproveAB_Stats::analyze( $test, $results, $segment ) ); $lines = array(); // The status in the words the screens use for it, not the stored key. $statuses = array( 'running' => __( 'Running', 'improveab-ab-testing' ), 'paused' => __( 'Paused', 'improveab-ab-testing' ), 'draft' => __( 'Draft', 'improveab-ab-testing' ), 'stopped' => __( 'Stopped', 'improveab-ab-testing' ), ); // Mail apps show plain text in a proportional font, so the title // gets no ==== underline (it never matched the title's width). $lines[] = $test['name']; $lines[] = ''; /* translators: %s: the test's status, e.g. Running. */ $lines[] = sprintf( __( 'Status: %s', 'improveab-ab-testing' ), isset( $statuses[ $test['status'] ] ) ? $statuses[ $test['status'] ] : $test['status'] ); if ( '' !== $segment ) { /* translators: %s: the one device class the numbers below cover, e.g. Mobile. */ $lines[] = sprintf( __( 'Device: %s', 'improveab-ab-testing' ), $this->device_name( $segment ) ); } // Stored in UTC; said in the site's own time zone and its own date // and time format, as a person writes it (the downloaded file keeps // the sortable 2026-10-06 15:25). $when = get_option( 'date_format' ) . ', ' . get_option( 'time_format' ); if ( ! empty( $test['started_at'] ) ) { /* translators: %s: the date and time the test started. */ $lines[] = sprintf( __( 'Started: %s', 'improveab-ab-testing' ), wp_date( $when, strtotime( $test['started_at'] . ' UTC' ) ) ); } if ( ! empty( $test['ended_at'] ) ) { /* translators: %s: the date and time the test stopped. */ $lines[] = sprintf( __( 'Stopped: %s', 'improveab-ab-testing' ), wp_date( $when, strtotime( $test['ended_at'] . ' UTC' ) ) ); } // The winner once one is declared; until then the result as the // results page words it, the traffic split warning first, so a // summary never shows broken numbers as if they could be read. $winner = empty( $test['winner_variant'] ) ? null : ImproveAB_Tests::variant_by_key( $test, $test['winner_variant'] ); if ( $winner ) { /* translators: 1: the variant's letter, A to F; 2: the variant's name. */ $named = sprintf( _x( '%1$s · %2$s', 'variant letter and name', 'improveab-ab-testing' ), $winner[0], $winner[1] ); /* translators: %s: the winner's letter and name, e.g. "B · Variant B". */ $lines[] = sprintf( __( 'Winner: %s', 'improveab-ab-testing' ), $named ); } elseif ( 'draft' !== $test['status'] ) { $result = $this->result_words( $test, $analysis, $segment ); if ( '' !== $result ) { /* translators: %s: the result in words, e.g. "No clear winner yet." */ $lines[] = sprintf( __( 'Result: %s', 'improveab-ab-testing' ), $result ); } } /* translators: %s: what the test counts as a conversion, e.g. "Visit a page (contains /thank-you/)". */ $lines[] = sprintf( __( 'Goal: %s', 'improveab-ab-testing' ), $this->goal_words( $test ) ); $lines[] = ''; // One line per variant, named as on the results page: its letter, // then its name (an old default name reads as today's). foreach ( array_values( (array) $analysis['variants'] ) as $i => $v ) { $imps = (int) $v['impressions']; $convs = (int) $v['conversions']; $rate = ImproveAB_Stats::percent( $convs, $imps ); $name = sprintf( /* translators: 1: the variant's letter, A to F; 2: the variant's name. */ _x( '%1$s · %2$s', 'variant letter and name', 'improveab-ab-testing' ), ImproveAB_Tests::variant_letter( $i ), ImproveAB_Tests::display_label( isset( $v['label'] ) ? $v['label'] : $v['key'], $i ) ); // A purchase goal's summary carries each version's revenue, in the // store's own format: it is the number a store owner forwards it for. if ( 'purchase' === $test['goal_type'] ) { $money = static function ( $key ) use ( $v ) { return isset( $v[ $key . '_text' ] ) ? (string) $v[ $key . '_text' ] : number_format_i18n( isset( $v[ $key ] ) ? (float) $v[ $key ] : 0, 2 ); }; $lines[] = sprintf( /* translators: %1$s: variant label, %2$s: e.g. "120 visitors", %3$s: e.g. "6 conversions", %4$s: conversion rate, e.g. 5.00%, %5$s: the version's revenue, e.g. $157.50, %6$s: its revenue per visitor, e.g. $1.31. */ __( '%1$s: %2$s, %3$s (%4$s), revenue %5$s (%6$s per visitor)', 'improveab-ab-testing' ), $name, /* translators: %s: number of visitors. */ sprintf( _n( '%s visitor', '%s visitors', $imps, 'improveab-ab-testing' ), number_format_i18n( $imps ) ), /* translators: %s: number of conversions. */ sprintf( _n( '%s conversion', '%s conversions', $convs, 'improveab-ab-testing' ), number_format_i18n( $convs ) ), number_format_i18n( $rate, 2 ) . '%', $money( 'revenue' ), $money( 'rpv' ) ); continue; } $lines[] = sprintf( /* translators: %1$s: variant label, %2$s: e.g. "120 visitors", %3$s: e.g. "6 conversions", %4$s: conversion rate, e.g. 5.00%. */ __( '%1$s: %2$s, %3$s (%4$s)', 'improveab-ab-testing' ), $name, /* translators: %s: number of visitors. */ sprintf( _n( '%s visitor', '%s visitors', $imps, 'improveab-ab-testing' ), number_format_i18n( $imps ) ), /* translators: %s: number of conversions. */ sprintf( _n( '%s conversion', '%s conversions', $convs, 'improveab-ab-testing' ), number_format_i18n( $convs ) ), number_format_i18n( $rate, 2 ) . '%' ); } if ( ! empty( $test['notes'] ) ) { $lines[] = ''; $lines[] = __( 'Notes:', 'improveab-ab-testing' ); $lines[] = $test['notes']; } $lines[] = ''; /* translators: %s: the address of the test's results page in wp-admin. */ $lines[] = sprintf( __( 'See the full results: %s', 'improveab-ab-testing' ), admin_url( 'admin.php?page=improveab#/test/' . (int) $test['id'] ) ); $lines[] = ''; /* translators: %s: the site's home address. */ $lines[] = sprintf( __( 'Sent by ImproveAB from %s', 'improveab-ab-testing' ), home_url( '/' ) ); return implode( "\n", $lines ) . "\n"; } /** * The goal in words, as the results page names it: the goal, then what * it watches ("Visit a page (contains /thank-you/)"). * * @param array $test Hydrated test. * @return string */ private function goal_words( $test ) { $names = array( 'url' => __( 'Visit a page', 'improveab-ab-testing' ), 'click' => __( 'Click an element', 'improveab-ab-testing' ), 'link_click' => __( 'Click a link', 'improveab-ab-testing' ), 'form' => __( 'Form submission', 'improveab-ab-testing' ), 'add_to_cart' => __( 'Add to cart', 'improveab-ab-testing' ), 'purchase' => __( 'WooCommerce purchase', 'improveab-ab-testing' ), 'scroll' => __( 'Scroll depth', 'improveab-ab-testing' ), 'time' => __( 'Time on page', 'improveab-ab-testing' ), 'active_time' => __( 'Active time on page', 'improveab-ab-testing' ), ); $type = (string) $test['goal_type']; $config = is_array( $test['goal_config'] ) ? $test['goal_config'] : array(); $value = isset( $config['value'] ) ? trim( (string) $config['value'] ) : ''; $name = isset( $names[ $type ] ) ? $names[ $type ] : $type; $detail = ''; if ( 'url' === $type && '' !== $value ) { $detail = isset( $config['match'] ) && 'exact' === $config['match'] /* translators: %s: page path the goal page must be, e.g. /thank-you/. */ ? sprintf( __( 'is %s', 'improveab-ab-testing' ), $value ) /* translators: %s: text the goal page's path must contain, e.g. /thank-you/. */ : sprintf( __( 'contains %s', 'improveab-ab-testing' ), $value ); } elseif ( ( 'click' === $type || 'form' === $type ) && ! empty( $config['selector'] ) ) { $detail = (string) $config['selector']; } elseif ( 'link_click' === $type && '' !== $value ) { /* translators: %s: text the clicked link's address must contain. */ $detail = sprintf( __( 'href contains %s', 'improveab-ab-testing' ), $value ); } elseif ( 'add_to_cart' === $type && ! empty( $config['product_id'] ) ) { /* translators: %s: WooCommerce product ID. */ $detail = sprintf( __( 'product #%s', 'improveab-ab-testing' ), absint( $config['product_id'] ) ); } elseif ( 'scroll' === $type && ! empty( $config['percent'] ) ) { $detail = absint( $config['percent'] ) . '%'; } elseif ( ( 'time' === $type || 'active_time' === $type ) && ! empty( $config['seconds'] ) ) { $secs = absint( $config['seconds'] ); /* translators: %s: number of seconds. */ $detail = sprintf( _n( '%s second', '%s seconds', $secs, 'improveab-ab-testing' ), number_format_i18n( $secs ) ); } /* translators: %1$s: goal name, e.g. Visit a page. %2$s: its setting, e.g. "contains /thank-you/" or "75%". */ return '' !== $detail ? sprintf( __( '%1$s (%2$s)', 'improveab-ab-testing' ), $name, $detail ) : $name; } /** * Return per-day performance for charting. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function get_timeseries( $request ) { $id = absint( $request['id'] ); $test = ImproveAB_Tests::get( $id ); if ( ! $test ) { return new WP_Error( 'improveab_not_found', ImproveAB_Tests::gone_text(), array( 'status' => 404 ) ); } $rows = ImproveAB_Tests::timeseries( $id ); return new WP_REST_Response( array( 'rows' => $rows, 'variants' => $test['variants'], ), 200 ); } /** * Return published content grouped by post type, for the preview picker. * * @return WP_REST_Response */ public function list_content() { $types = get_post_types( array( 'public' => true ), 'objects' ); $groups = array(); foreach ( $types as $type ) { if ( 'attachment' === $type->name ) { continue; } // Titles and addresses only: a big catalogue's post meta (a store's // products carry dozens of rows each) is not loaded for them. $posts = get_posts( array( 'post_type' => $type->name, 'post_status' => 'publish', 'numberposts' => 100, 'orderby' => 'title', 'order' => 'ASC', 'suppress_filters' => false, 'update_post_meta_cache' => false, ) ); if ( empty( $posts ) ) { continue; } $items = array(); foreach ( $posts as $p ) { // Plain text: get_the_title() returns the title filters' HTML // entities (a curly apostrophe, an ampersand), and both apps // escape what they show, which printed "’" in the list. $title = html_entity_decode( wp_strip_all_tags( get_the_title( $p ) ), ENT_QUOTES, 'UTF-8' ); $items[] = array( 'title' => '' !== $title ? $title : sprintf( '#%d', $p->ID ), 'url' => get_permalink( $p ), ); } $groups[] = array( 'label' => $type->labels->name, 'items' => $items, ); } return new WP_REST_Response( array( 'groups' => $groups ), 200 ); } /** * Duplicate a test. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function duplicate_test( $request ) { $test = ImproveAB_Tests::duplicate( absint( $request['id'] ) ); if ( is_wp_error( $test ) ) { return $test; } return new WP_REST_Response( $this->with_trim_notice( array( 'test' => $test ) ), 201 ); } /** * What this install allows, as the settings routes report it. * * The admin app receives the same facts through its localized config; * exposing them here lets anything that only speaks REST (a staging * check, a monitoring script) read them without probing a route. * * @return array */ private function plan_summary() { $plan = array( 'max_variants' => improveab_max_variants(), 'max_active_tests' => improveab_max_active_tests(), 'goal_types' => array_values( improveab_goal_types() ), ); return $plan; } /** * The requesting user's own admin preferences (see improveab_user_prefs()). * * @return WP_REST_Response */ public function get_prefs() { return rest_ensure_response( array( 'prefs' => improveab_user_prefs() ) ); } /** * Update the requesting user's own preferences: `tour`, `walk` and `howto` * (true marks that guide seen, false shows it again, as "Replay the intro * tour" does) and `view` (`cards` or `table`). Anything else is ignored; * nobody can write another user's preferences through this route. * * @param WP_REST_Request $req Request. * @return WP_REST_Response */ public function update_prefs( WP_REST_Request $req ) { $user = get_current_user_id(); foreach ( array( 'tour', 'walk', 'howto' ) as $key ) { $val = $req->get_param( $key ); if ( null === $val || ! is_scalar( $val ) ) { continue; } if ( rest_sanitize_boolean( $val ) ) { update_user_option( $user, 'improveab_' . $key . '_seen', 1 ); } else { delete_user_option( $user, 'improveab_' . $key . '_seen' ); } } $view = $req->get_param( 'view' ); if ( is_string( $view ) && in_array( $view, array( 'cards', 'table' ), true ) ) { update_user_option( $user, 'improveab_results_view', $view ); } return rest_ensure_response( array( 'prefs' => improveab_user_prefs() ) ); } /** * Get plugin settings, plus the `plan` block (what this copy of the * plugin offers) and which roles besides administrator hold the * manage-tests capability now. * * @return WP_REST_Response */ public function get_settings() { return new WP_REST_Response( array( 'settings' => ImproveAB_Settings::for_admin(), 'plan' => $this->plan_summary(), 'access' => ImproveAB_Settings::access_roles(), ), 200 ); } /** * Update plugin settings. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function update_settings( $request ) { $data = $request->get_json_params(); $stored = ImproveAB_Settings::update( is_array( $data ) ? $data : array() ); if ( is_wp_error( $stored ) ) { return $stored; } return new WP_REST_Response( array( 'settings' => ImproveAB_Settings::for_admin(), 'plan' => $this->plan_summary(), 'access' => ImproveAB_Settings::access_roles(), ), 200 ); } }