prefix}improveab_tests / _variants / _events / _daily / _tally / * _buckets). Direct $wpdb access is the intended mechanism for custom * tables: every statement names its table as {$wpdb->prefix}improveab_ * in the statement itself, every value goes through $wpdb->prepare() (an * IN() list gets one placeholder per value) or wpdb's format arrays, and hot * reads are runtime-cached via wp_cache_* (see flush_cache()). Only the * direct-query and caching sniffs are switched off for the file; the * prepared-SQL sniffs stay on, 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. /** * Data access and validation for tests and variants. */ class ImproveAB_Tests { const GOAL_TYPES = array( 'url', 'click', 'link_click', 'form', 'add_to_cart', 'scroll', 'time', 'active_time', 'purchase' ); const MAX_VARIANTS = 6; /** * How many variants the last create() dropped to meet the variant ceiling * (six, or lower if a site filtered improveab_max_variants), so the REST * layer can say so: a copy of a six-variant test on a site with a lower * ceiling must not lose variants in silence. * * @var int */ private static $last_trim = 0; /** * The create lock's option row (ImproveAB_Install::lock()), held from a * create's name check to its commit (hold_creates()). */ const CREATE_LOCK = 'improveab_create_lock'; /** * Depth of this request's hold on the create lock. * * @var int */ private static $create_depth = 0; // Bounds for the free-form per-test meta object (see sanitize_meta()). // Generous enough for a feature's own configuration, small enough that a // test row stays cheap to load on every admin request. const META_MAX_KEYS = 50; const META_MAX_DEPTH = 3; const META_MAX_BYTES = 20000; const META_MAX_STRING = 2000; // The meta key that records when a started test's traffic split check // last started over (UTC 'Y-m-d H:i:s'): the check counts the visitors // from that moment on. The plugin's own; a meta write never sets, moves // or clears it. const SPLIT_SINCE = 'improveab_split_since'; // The meta key where create() and a draft's update() record, for a URL // test, the post each address on this site named when it was saved // ({ variant_key: post id }); moved_addresses() compares it with what // the address names now. The plugin's own; a meta write never sets, // moves or clears it. const URL_POSTS = 'improveab_url_posts'; const CACHE_GROUP = 'improveab'; const CACHE_KEY = 'running_with_variants'; const CACHE_KEY_SERVING = 'serving_winners'; const CACHE_KEY_IDLE = 'idle_with_variants'; const CACHE_KEY_PAUSED = 'paused_elements'; const CACHE_TTL = 300; // 5 minutes; a backstop — writes invalidate immediately. // What a visitor's page view and beacon are built from (CONTRACTS 3), // stored once per cache generation: the running and served tests' // finished pieces, the idle element tests' class names, and the running // tests by slug for /event. Each carries the generation it was built // for and the build that wrote it, and is used however old it is. const STORE_LIVE = 'improveab_runtime_cache'; const STORE_IDLE = 'improveab_runtime_idle'; const STORE_EVENTS = 'improveab_runtime_events'; // An idle rule up to this long is printed whole on every page (it then // covers markup a script adds too); past it, each page carries only the // selectors of the classes in its own markup. const IDLE_INLINE_MAX = 8192; /** * The day cuts one chart statement can tell apart (compute_timeseries()): * no zone in the time zone database has needed more than eight over any * span from 2000 to now. */ const DAY_CUTS = 8; /** * The visitor stores this request has read or built, by name. * * @var array */ private static $front = array(); // Names a slug never takes, because "improveab:{slug}" would be one of the // runtime's own storage keys (CONTRACTS §8.1). Later global keys carry a // character no slug can hold, so this list never needs to grow. const RESERVED_SLUGS = array( 'vid', 'seen', 'ho', 'vt' ); /** * Invalidate the cached set of running tests. * * Called from every method that can change which tests run or how their * variants are configured, so the front-end never serves stale assignments. * The admin aggregates have a generation of their own (flush_data()): a * rename or a note kept a slow results page, the list's totals and the * split map, and every write used to throw them away. * * @return void */ public static function flush_cache() { wp_cache_delete( self::CACHE_KEY . '_' . IMPROVEAB_BUILD, self::CACHE_GROUP ); wp_cache_delete( self::CACHE_KEY_SERVING . '_' . IMPROVEAB_BUILD, self::CACHE_GROUP ); wp_cache_delete( self::CACHE_KEY_IDLE . '_' . IMPROVEAB_BUILD, self::CACHE_GROUP ); wp_cache_delete( self::CACHE_KEY_PAUSED . '_' . IMPROVEAB_BUILD, self::CACHE_GROUP ); // The visitor stores (front()), and a new generation so a visitor // request that read the tables before this write does not store its // copy afterwards. self::$front = array(); foreach ( array( self::STORE_LIVE, self::STORE_IDLE, self::STORE_EVENTS ) as $improveab_store ) { delete_option( $improveab_store ); wp_cache_delete( $improveab_store . '_' . IMPROVEAB_BUILD, self::CACHE_GROUP ); } update_option( 'improveab_cache_gen', wp_generate_uuid4(), true ); } /** * Start a new generation of the admin aggregates kept by remember() * (results, the time series, the list's totals) and of the split map, * after a change that can make one of them wrong: a test deleted (its id * can come back), a status change (the split map and the list follow the * running and paused tests), a restarted split check, tables recreated. Counting new visitors needs none: a kept aggregate is * kept for at most a few minutes. * * @return void */ public static function flush_data() { update_option( 'improveab_data_gen', wp_generate_uuid4(), true ); if ( class_exists( 'ImproveAB_Stats' ) ) { ImproveAB_Stats::forget_split_map(); } } /** * The current generation of the admin aggregates (flush_data()). * * @return string */ public static function data_gen() { return (string) get_option( 'improveab_data_gen', '' ); } /** * A short list of tests in the given statuses, newest first: id, name, * type and status only. One cheap query for the admin bar, which only a * signed-in manager's page load ever asks for. * * @param string[] $statuses Statuses to include. * @param int $limit Maximum rows. * @return array */ public static function recent_by_status( $statuses, $limit = 20 ) { global $wpdb; $statuses = array_values( array_intersect( (array) $statuses, array( 'draft', 'paused', 'stopped', 'running' ) ) ); if ( empty( $statuses ) ) { return array(); } $args = $statuses; $args[] = max( 1, absint( $limit ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, name, type, status FROM {$wpdb->prefix}improveab_tests WHERE status IN (" . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ') ORDER BY id DESC LIMIT %d', $args ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * How many tests are in each status, in one grouped query: the admin * bar's group counts, which only a signed-in manager's page load asks * for. recent_by_status() lists a few rows, this counts them all. * * @return array Map of status => number of tests. */ public static function count_by_status() { global $wpdb; $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS n FROM {$wpdb->prefix}improveab_tests GROUP BY status", ARRAY_A ); $out = array(); foreach ( (array) $rows as $row ) { $out[ (string) $row['status'] ] = (int) $row['n']; } return $out; } /** * Get all tests with their variants. * * The summary (the tests list, which reads its rows and nothing else) * leaves out what only a test's own page shows: the description, notes * and meta, and each variant's edits, which a point-and-click variant * fills with up to 20,000 characters of markup. * * @param bool $summary Whether to leave those out. * @return array */ public static function all( $summary = false ) { global $wpdb; $rows = $summary ? $wpdb->get_results( "SELECT id, uuid, name, slug, type, status, goal_type, goal_config, weights, targeting, winner_variant, serve_winner, notified_winner, notified_srm, started_at, ended_at, created_at FROM {$wpdb->prefix}improveab_tests ORDER BY created_at DESC", ARRAY_A ) : $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}improveab_tests ORDER BY created_at DESC", ARRAY_A ); if ( empty( $rows ) ) { return array(); } // Load every test's variants in one IN() query instead of one query // per test (mirrors running_with_variants()), so the tests list stays // at two queries total however many tests exist. $ids = array_map( 'intval', wp_list_pluck( $rows, 'id' ) ); $variants = $summary ? $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key, label, class_name, url, sort_order FROM {$wpdb->prefix}improveab_variants WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') ORDER BY test_id ASC, sort_order ASC', $ids ), ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key, label, class_name, url, sort_order, edits FROM {$wpdb->prefix}improveab_variants WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') ORDER BY test_id ASC, sort_order ASC', $ids ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return array(); } $by_test = array(); foreach ( (array) $variants as $v ) { $tid = (int) $v['test_id']; // Drop the grouping key so each variant row has exactly the shape // hydrate() produces. unset( $v['test_id'] ); if ( ! $summary ) { $v['edits'] = ! empty( $v['edits'] ) ? json_decode( $v['edits'], true ) : null; } $by_test[ $tid ][] = $v; } $out = array(); foreach ( $rows as $row ) { // The tally's watermark is the plugin's own bookkeeping. unset( $row['tallied_id'] ); $row['id'] = (int) $row['id']; $row['serve_winner'] = (int) $row['serve_winner']; $row['goal_config'] = $row['goal_config'] ? json_decode( $row['goal_config'], true ) : array(); $row['weights'] = $row['weights'] ? json_decode( $row['weights'], true ) : array(); $row['targeting'] = ! empty( $row['targeting'] ) ? json_decode( $row['targeting'], true ) : array(); if ( ! $summary ) { // all() builds its rows by hand rather than through hydrate(), // so the decode is repeated here; the is_array guard is worth // a line because meta, unlike the other JSON columns, is // written by code this plugin does not own. $meta = ! empty( $row['meta'] ) ? json_decode( $row['meta'], true ) : array(); $row['meta'] = is_array( $meta ) ? $meta : array(); } $row['variants'] = isset( $by_test[ $row['id'] ] ) ? $by_test[ $row['id'] ] : array(); $out[] = $row; } return $out; } /** * The newest running tests, at most $limit, with the number of variants * each has: what the dashboard widget lists, in two small queries * however many tests the site keeps. * * @param int $limit Maximum rows. * @return array Rows of { id, name, status, variants (count) }. */ public static function running_glance( $limit ) { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, name, status FROM {$wpdb->prefix}improveab_tests WHERE status = 'running' ORDER BY created_at DESC LIMIT %d", max( 1, absint( $limit ) ) ), ARRAY_A ); if ( empty( $rows ) ) { return array(); } $ids = array_map( 'intval', wp_list_pluck( $rows, 'id' ) ); $counts = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, COUNT(*) AS n FROM {$wpdb->prefix}improveab_variants WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') GROUP BY test_id', $ids ), ARRAY_A ); $by = array(); foreach ( (array) $counts as $c ) { $by[ (int) $c['test_id'] ] = (int) $c['n']; } foreach ( $rows as &$row ) { $row['id'] = (int) $row['id']; $row['variants'] = isset( $by[ $row['id'] ] ) ? $by[ $row['id'] ] : 0; } unset( $row ); return $rows; } /** * Running tests with their variants, loaded in at most two queries * regardless of how many tests exist: the admin-bar menu and the * picker's list among the readers. A visitor's page view is built from * front() instead. * * @return array */ public static function running_with_variants() { return self::cached_set( self::CACHE_KEY, 'running' ); } /** * Ended tests whose declared winner is being served to every visitor * ("keep showing the winner" mode). Same shape and caching as * running_with_variants(); the runtime shows the winner without * assigning, tracking or attaching goals. * * @return array */ public static function serving_winners() { return self::cached_set( self::CACHE_KEY_SERVING, 'serving' ); } /** * Element tests that exist but neither run nor serve a winner (drafts, * paused, stopped without a kept winner), as slug and class names. * * @return array */ public static function idle_with_variants() { return self::cached_set( self::CACHE_KEY_IDLE, 'idle', static function ( $row ) { $variants = array(); foreach ( $row['variants'] as $v ) { $variants[] = array( 'variant_key' => $v['variant_key'], 'class_name' => $v['class_name'], ); } return array( 'slug' => $row['slug'], 'type' => $row['type'], 'variants' => $variants, ); } ); } /** * Paused element and visual tests as id, name, slug and what finds * their element on a page: an element test's class names pasted on its * versions, a visual test's selector. The point-and-click panel refuses * an element inside, around or carrying one of them as it does a * running test's, because the test takes its visitors back when it is * resumed. Read only for a manager's page view, like * running_with_variants(). * * @return array */ public static function paused_elements() { return self::cached_set( self::CACHE_KEY_PAUSED, 'paused', static function ( $row ) { $item = array( 'id' => (int) $row['id'], 'slug' => $row['slug'], 'name' => $row['name'], // Where it runs: the panel lets a pick through on a page // its rule keeps it off (ImproveAB_Render::paused_manifest()). 'pages' => isset( $row['targeting']['pages'] ) && is_array( $row['targeting']['pages'] ) ? $row['targeting']['pages'] : array(), ); if ( 'visual' === $row['type'] ) { // The first version that names one, as the panel's list // of running tests reads it. $item['selector'] = ''; foreach ( $row['variants'] as $v ) { if ( is_array( $v['edits'] ) && ! empty( $v['edits']['selector'] ) ) { $item['selector'] = (string) $v['edits']['selector']; break; } } } else { $item['classes'] = array_values( array_filter( wp_list_pluck( $row['variants'], 'class_name' ) ) ); } return $item; } ); } /** * Whether any element test exists, in any state. Its versions are markup * in the site's own pages that only this plugin hides, which the Plugins * screen warns about before the plugin is deactivated. * * @return bool */ public static function has_element_tests() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; read only on the Plugins screen. return null !== $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `{$wpdb->prefix}improveab_tests` WHERE type = %s LIMIT 1", 'element' ) ); } /** * Load a set of tests with their variants in at most two queries and * keep it in the object cache (with a persistent one, across requests). * * @param string $cache_key Object-cache key. * @param string $set Which set (read_set()). * @param callable|null $shape Reduces each row to what its reader needs. * @return array */ private static function cached_set( $cache_key, $set, $shape = null ) { // Keyed by plugin version as well: a persistent object cache (Redis, // Memcached) outlives a plugin update, and the entry has a five // minute life in which the new code would read rows the old code // wrote. Stale keys of the previous version simply expire. $cache_key = $cache_key . '_' . IMPROVEAB_BUILD; // The cache generation as this request first saw it, before anything // below reads the tables. Every entry is tagged with it, and one // tagged with an older generation is a miss: a request that read the // rows just before an admin write (a Stop, a Start) and stored them // after that write's flush would otherwise serve them from a // persistent object cache for the entry's whole life. $gen = (string) get_option( 'improveab_cache_gen', '' ); $cached = wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( is_array( $cached ) && isset( $cached['g'] ) && array_key_exists( 'v', $cached ) && is_array( $cached['v'] ) && $gen === $cached['g'] ) { return $cached['v']; } $out = self::read_set( $set, $shape ); // A failed read is never cached: one hiccup would switch every test // off until the next admin write. if ( null === $out ) { return array(); } wp_cache_set( $cache_key, array( 'g' => $gen, 'v' => $out ), self::CACHE_GROUP, self::CACHE_TTL ); return $out; } /** * Read one set of tests with their variants, in two queries: 'running', * 'serving' (stopped, their declared winner kept showing), 'idle' * (element tests that neither run nor serve a winner), 'paused' * (element and visual tests) or 'front' (what the visitor stores are * built from: the running tests, every element test and the served * winners). * * @param string $set Which set. * @param callable|null $shape Reduces each row to what its reader needs. * @return array|null Rows, or null when a query failed. */ private static function read_set( $set, $shape = null ) { global $wpdb; // The callers keep what this reads (cached_set(), the visitor // stores). Every column except `meta`, `description` and `notes`, // named rather than selected with * on purpose: this is the // front-end path, and a feature's per-test state and the admin's own // text (notes run to 5,000 characters) have no business in a // visitor's page load, so they are loaded only by get() and all(). // Add new columns to each statement as well. switch ( $set ) { case 'running': $rows = $wpdb->get_results( "SELECT id, uuid, name, slug, type, status, goal_type, goal_config, targeting, winner_variant, serve_winner, notified_winner, notified_srm, started_at, ended_at, created_at FROM {$wpdb->prefix}improveab_tests WHERE status = 'running' ORDER BY id ASC", ARRAY_A ); break; case 'serving': $rows = $wpdb->get_results( "SELECT id, uuid, name, slug, type, status, goal_type, goal_config, targeting, winner_variant, serve_winner, notified_winner, notified_srm, started_at, ended_at, created_at FROM {$wpdb->prefix}improveab_tests WHERE status = 'stopped' AND serve_winner = 1 AND winner_variant IS NOT NULL AND winner_variant <> '' ORDER BY id ASC", ARRAY_A ); break; case 'idle': $rows = $wpdb->get_results( "SELECT id, uuid, name, slug, type, status, goal_type, goal_config, targeting, winner_variant, serve_winner, notified_winner, notified_srm, started_at, ended_at, created_at FROM {$wpdb->prefix}improveab_tests WHERE type = 'element' AND status <> 'running' AND NOT (status = 'stopped' AND serve_winner = 1 AND winner_variant IS NOT NULL AND winner_variant <> '') ORDER BY id ASC", ARRAY_A ); break; case 'paused': $rows = $wpdb->get_results( "SELECT id, uuid, name, slug, type, status, goal_type, goal_config, targeting, winner_variant, serve_winner, notified_winner, notified_srm, started_at, ended_at, created_at FROM {$wpdb->prefix}improveab_tests WHERE type IN ('element', 'visual') AND status = 'paused' ORDER BY id ASC", ARRAY_A ); break; case 'front': $rows = $wpdb->get_results( "SELECT id, uuid, name, slug, type, status, goal_type, goal_config, targeting, winner_variant, serve_winner, notified_winner, notified_srm, started_at, ended_at, created_at FROM {$wpdb->prefix}improveab_tests WHERE status = 'running' OR type = 'element' OR (status = 'stopped' AND serve_winner = 1 AND winner_variant IS NOT NULL AND winner_variant <> '') ORDER BY id ASC", ARRAY_A ); break; default: return null; } // An empty result from a FAILED query (a lost connection, a lock // wait, a missing table) must never be taken for "no tests". if ( '' !== $wpdb->last_error ) { return null; } if ( empty( $rows ) ) { return array(); } $ids = array_map( 'intval', wp_list_pluck( $rows, 'id' ) ); $variants = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key, label, class_name, url, sort_order, edits FROM {$wpdb->prefix}improveab_variants WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') ORDER BY test_id ASC, sort_order ASC', $ids ), ARRAY_A ); // Tests read without their variants would show every version of an // element test stacked, so a failed read is no read at all. if ( '' !== $wpdb->last_error ) { return null; } $by_test = array(); foreach ( (array) $variants as $v ) { $v['edits'] = ! empty( $v['edits'] ) ? json_decode( $v['edits'], true ) : null; $by_test[ (int) $v['test_id'] ][] = $v; } $out = array(); foreach ( $rows as $row ) { $row['id'] = (int) $row['id']; $row['serve_winner'] = (int) $row['serve_winner']; $row['goal_config'] = $row['goal_config'] ? json_decode( $row['goal_config'], true ) : array(); $row['targeting'] = ! empty( $row['targeting'] ) ? json_decode( $row['targeting'], true ) : array(); $row['variants'] = isset( $by_test[ $row['id'] ] ) ? $by_test[ $row['id'] ] : array(); $out[] = $shape ? call_user_func( $shape, $row ) : $row; } return $out; } /** * What a visitor's page view is built from: the running and served * tests' finished pieces (ImproveAB_Render::pieces() joined: the head's * hide rules, each test's runtime config as JSON, the URL tests' * redirect entries), the idle rule when it is short, and whether pages * must be scanned for idle classes when it is not. * * @return array */ public static function front() { return self::front_store( self::STORE_LIVE ); } /** * The idle element tests' other versions' class names, as one string * with a space before and after each: read only to scan a page's own * markup when the idle rule is too long to print whole. * * @return string */ public static function front_idle_classes() { $store = self::front_store( self::STORE_IDLE ); return isset( $store['c'] ) ? (string) $store['c'] : ''; } /** * A running test as /event needs it (its pieces() entry): id, variant * keys and the hosts its variant pages live on (for a handoff arriving * from one of them); null for any slug that is not running. * * @param string $slug Test slug. * @return array|null */ public static function event_test( $slug ) { $store = self::front_store( self::STORE_EVENTS ); return isset( $store['m'][ $slug ] ) && is_array( $store['m'][ $slug ] ) ? $store['m'][ $slug ] : null; } /** * One visitor store: from this request's memory, the persistent object * cache or its option, when it was built for the current generation by * this build; else built now (all three at once) from the tables. * * @param string $name Store name. * @return array */ private static function front_store( $name ) { if ( isset( self::$front[ $name ] ) ) { return self::$front[ $name ]; } $gen = (string) get_option( 'improveab_cache_gen', '' ); $hit = wp_using_ext_object_cache() ? wp_cache_get( $name . '_' . IMPROVEAB_BUILD, self::CACHE_GROUP ) : get_option( $name ); if ( is_array( $hit ) && isset( $hit['g'], $hit['b'] ) && $gen === $hit['g'] && IMPROVEAB_BUILD === $hit['b'] ) { self::$front[ $name ] = $hit; return $hit; } $built = self::build_front(); if ( null === $built ) { // A failed read: nothing runs for this request, nothing is kept. return array(); } self::$front = array_merge( self::$front, $built ); return isset( self::$front[ $name ] ) ? self::$front[ $name ] : array(); } /** * Build the three visitor stores from the tables (two queries) and keep * them, unless another request is keeping them at this moment (one * writer at a time; the others serve what they built) or an admin write * changed the generation while the rows were read. * * @return array|null The stores by name, or null when a read failed. */ private static function build_front() { global $wpdb; // The generation straight from the table, before the rows: what the // stores are tagged with. This request's options cache may predate it. $gen = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'improveab_cache_gen' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the cached value is exactly what must be bypassed. $rows = self::read_set( 'front' ); if ( null === $rows ) { return null; } $live = array( 'g' => $gen, 'b' => IMPROVEAB_BUILD, 'n' => 0, 'slugs' => array(), 'kept' => '', 'other' => '', 'vis' => array(), 'cfg' => array(), 'url' => array(), 'buy' => array(), 'idle' => '', 'scan' => false ); $events = array( 'g' => $gen, 'b' => IMPROVEAB_BUILD, 'm' => array() ); $kept = array(); $other = array(); $idle = array(); foreach ( $rows as $test ) { $serving = 'stopped' === $test['status'] && ! empty( $test['serve_winner'] ) && '' !== (string) $test['winner_variant']; if ( 'running' !== $test['status'] && ! $serving ) { // Idle: only an element test's markup stays on pages. foreach ( $test['variants'] as $v ) { if ( 'default' !== (string) $v['variant_key'] && ! empty( $v['class_name'] ) && preg_match( '/^[A-Za-z0-9_-]+$/', $v['class_name'] ) ) { $idle[] = $v['class_name']; } } continue; } if ( $serving ) { $test['serve'] = (string) $test['winner_variant']; } $piece = ImproveAB_Render::pieces( $test ); ++$live['n']; $live['slugs'][] = $test['slug']; $kept = array_merge( $kept, $piece['kept'] ); $other = array_merge( $other, $piece['other'] ); if ( $piece['vis'] ) { $live['vis'][] = $piece['vis']; } $live['cfg'][] = $piece['cfg']; if ( $piece['url'] ) { $live['url'][] = $piece['url']; } if ( ! $serving ) { if ( 'purchase' === $test['goal_type'] ) { $live['buy'][] = (int) $test['id']; } $events['m'][ $test['slug'] ] = $piece['event']; } } $live['kept'] = implode( ',', $kept ); $live['other'] = implode( ',', $other ); $idle_store = array( 'g' => $gen, 'b' => IMPROVEAB_BUILD, 'c' => '' ); if ( $idle ) { $rule = '.' . implode( ImproveAB_Render::HIDE_WEIGHT . ',.', $idle ) . ImproveAB_Render::HIDE_WEIGHT; if ( strlen( $rule ) <= self::IDLE_INLINE_MAX ) { $live['idle'] = $rule; } else { $live['scan'] = true; $idle_store['c'] = ' ' . implode( ' ', $idle ) . ' '; } } $stores = array( self::STORE_LIVE => $live, self::STORE_IDLE => $idle_store, self::STORE_EVENTS => $events, ); self::keep_front( $stores, $gen ); return $stores; } /** * Keep the visitor stores: in the persistent object cache when there is * one, else one option each, autoloaded while small (a large one would * cost every request on the site, the admin's included, past the 64 KB * WordPress itself treats as the line). One request writes them per * generation: an INSERT IGNORE claim on `improveab_runtime_lock` decided * by the database, holding the time while the stores are written and the * generation once they are (a later request for the same generation * then has nothing to do); a claim older than 30 s is from a request * that died and is taken over. The others serve what they built. * Nothing is kept when an admin write changed the generation since the * rows were read. * * @param array $stores Stores by name. * @param string $gen Generation the rows were read under. */ private static function keep_front( $stores, $gen ) { global $wpdb; $ext = wp_using_ext_object_cache(); $done = 'gen:' . $gen; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic claim and reads past the options cache. if ( $ext ) { if ( wp_cache_get( 'runtime_built', self::CACHE_GROUP ) === $done || ! wp_cache_add( 'runtime_lock', 1, self::CACHE_GROUP, 30 ) ) { return; } } else { $now = (string) time(); $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", 'improveab_runtime_lock', $now ) ); if ( 1 !== (int) $wpdb->rows_affected ) { $held = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'improveab_runtime_lock' ) ); if ( $done === $held || ( preg_match( '/^\d+$/', $held ) && time() - (int) $held < 30 ) ) { return; } $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", $now, 'improveab_runtime_lock', $held ) ); if ( 1 !== (int) $wpdb->rows_affected ) { return; } } } $current = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'improveab_cache_gen' ) ); $kept = $current === $gen; if ( $kept ) { foreach ( $stores as $name => $value ) { if ( $ext ) { wp_cache_set( $name . '_' . IMPROVEAB_BUILD, $value, self::CACHE_GROUP, DAY_IN_SECONDS ); continue; } delete_option( $name ); add_option( $name, $value, '', strlen( (string) maybe_serialize( $value ) ) < 64 * 1024 ); } } if ( $ext ) { if ( $kept ) { wp_cache_set( 'runtime_built', $done, self::CACHE_GROUP, DAY_IN_SECONDS ); } wp_cache_delete( 'runtime_lock', self::CACHE_GROUP ); } else { $wpdb->update( $wpdb->options, array( 'option_value' => $kept ? $done : '0' ), array( 'option_name' => 'improveab_runtime_lock' ), array( '%s' ), array( '%s' ) ); } // phpcs:enable } /** * Get a single test with variants. * * @param int $id Test ID. * @return array|null */ public static function get( $id ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}improveab_tests WHERE id = %d", absint( $id ) ), ARRAY_A ); if ( ! $row ) { return null; } return self::hydrate( $row ); } /** * Get a single test with variants by its slug, whatever its status. * * @param string $slug Test slug. * @return array|null */ public static function get_by_slug( $slug ) { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}improveab_tests WHERE slug = %s", (string) $slug ), ARRAY_A ); return $row ? self::hydrate( $row ) : null; } /** * Attach variants and decode JSON fields onto a raw test row. * * @param array $row Raw DB row. * @return array */ private static function hydrate( $row ) { global $wpdb; $row['goal_config'] = $row['goal_config'] ? json_decode( $row['goal_config'], true ) : array(); $row['weights'] = $row['weights'] ? json_decode( $row['weights'], true ) : array(); $row['targeting'] = ! empty( $row['targeting'] ) ? json_decode( $row['targeting'], true ) : array(); // Always an array, whatever the column holds: meta is written by code // this plugin does not own, and a hand-edited row could decode to a // scalar that every consumer would then have to guard against. $meta = ! empty( $row['meta'] ) ? json_decode( $row['meta'], true ) : array(); $row['meta'] = is_array( $meta ) ? $meta : array(); $row['id'] = (int) $row['id']; $row['serve_winner'] = isset( $row['serve_winner'] ) ? (int) $row['serve_winner'] : 0; // The tally's watermark is the plugin's own bookkeeping. unset( $row['tallied_id'] ); $variants = $wpdb->get_results( $wpdb->prepare( "SELECT variant_key, label, class_name, url, sort_order, edits FROM {$wpdb->prefix}improveab_variants WHERE test_id = %d ORDER BY sort_order ASC", $row['id'] ), ARRAY_A ); $row['variants'] = array(); foreach ( (array) $variants as $v ) { $v['edits'] = ! empty( $v['edits'] ) ? json_decode( $v['edits'], true ) : null; $row['variants'][] = $v; } return $row; } /** * Create a test from sanitized request data. * * @param array $data Raw input. * @return array|WP_Error */ /** * A notice for the last create() when it dropped variants, or '' when it * kept them all. * * @return string */ public static function trim_notice() { if ( self::$last_trim < 1 ) { return ''; } $kept = improveab_max_variants(); return sprintf( /* translators: 1: variants kept, 2: variants in the source. */ __( 'Kept the first %1$d of %2$d variants: tests on this site run with up to %1$d variants.', 'improveab-ab-testing' ), $kept, $kept + self::$last_trim ); } public static function create( $data, $copy = false ) { $name = isset( $data['name'] ) ? self::sanitize_words( $data['name'] ) : ''; // Clamp to the column size (varchar 191 = 191 characters): on // strict-SQL servers an oversized value fails the whole insert // instead of truncating. mb_substr, so a multibyte name is neither // over-shortened nor cut mid-character (WP polyfills mb_* if needed). $name = mb_substr( $name, 0, 191 ); if ( '' === trim( $name ) ) { return new WP_Error( 'improveab_no_name', __( 'Please give the test a name.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } if ( self::name_exists( $name ) ) { return new WP_Error( 'improveab_dupe', __( 'A test with this name already exists. Please choose a different name.', 'improveab-ab-testing' ), array( 'status' => 409 ) ); } $type = isset( $data['type'] ) && in_array( $data['type'], array( 'element', 'url', 'visual' ), true ) ? $data['type'] : 'element'; // The New test form sends the slug its class preview showed: the // classes an owner copies before saving are the test's whatever this // site's own rule would make of the name (a German site spells "ü" // as "ue", a plugin may transliterate). A copy takes its name's. $want = $copy ? '' : self::requested_slug( isset( $data['slug'] ) ? $data['slug'] : '' ); // Re-indexed: variant keys are assigned by position below, and a JSON // object (or a sparse list) would otherwise pair variants with // missing keys and fail at the database with a generic error. $variants_in = isset( $data['variants'] ) && is_array( $data['variants'] ) ? array_values( $data['variants'] ) : array(); if ( count( $variants_in ) < 2 ) { return new WP_Error( 'improveab_variants', __( 'A test needs at least two variants.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } $max_variants = improveab_max_variants(); self::$last_trim = 0; if ( count( $variants_in ) > $max_variants ) { self::$last_trim = count( $variants_in ) - $max_variants; $variants_in = array_slice( $variants_in, 0, $max_variants ); } // A goal type the site switched off is refused rather than saved as // an empty page-visit goal that could never convert. A duplicate keeps // its source's goal whatever the site offers now. $goal_types = $copy ? self::GOAL_TYPES : improveab_goal_types(); if ( isset( $data['goal_type'] ) && ! in_array( $data['goal_type'], $goal_types, true ) ) { return new WP_Error( 'improveab_goal_type', __( 'That goal type is not available on this site. Choose another goal.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } $goal_type = isset( $data['goal_type'] ) ? $data['goal_type'] : 'url'; $goal_cfg = self::sanitize_goal( $goal_type, isset( $data['goal_config'] ) ? (array) $data['goal_config'] : array() ); // Sending visitors to another site is an administrator's call (see // may_set_admin_rules()); checked before anything is written. if ( 'url' === $type && ! self::may_set_admin_rules() ) { foreach ( $variants_in as $v ) { if ( isset( $v['url'] ) && self::off_site( esc_url_raw( self::as_string( $v['url'] ) ) ) ) { return self::offsite_error(); } } } // Visual (point-and-click) tests swap the text of one element on the // page, so every variant must carry the element's selector. Checked // before anything is written, so a bad request never leaves an // orphaned test row behind. if ( 'visual' === $type ) { foreach ( $variants_in as $i => $v ) { $e = self::sanitize_edits( isset( $v['edits'] ) ? $v['edits'] : array() ); if ( '' === $e['selector'] ) { return new WP_Error( 'improveab_selector', __( 'A visual test needs the selector of the element it changes.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } // The first version stands for the original and changes nothing. // Any other has to: stored without a change it would be started // all the same, and visitors split between two identical pages. // A copy is made of what this plugin applies (words and markup), // so it is held to that as well: a version with neither would // show visitors the original twice. if ( $i > 0 && ! self::changes_element( $e ) ) { return $copy ? self::copy_no_change_error() : self::no_change_error(); } } } $description = isset( $data['description'] ) ? self::sanitize_words( $data['description'], true ) : ''; $description = mb_substr( $description, 0, 280 ); $keys = self::variant_keys( count( $variants_in ) ); $in_targeting = ( isset( $data['targeting'] ) && is_array( $data['targeting'] ) ) ? $data['targeting'] : array(); // Every test splits its visitors evenly and takes every visitor except // staff; the request names only where it runs (its page scope). $weights = array_fill_keys( $keys, 1 ); $targeting = array( 'pages' => self::sanitize_pages( isset( $in_targeting['pages'] ) ? $in_targeting['pages'] : array() ) ); // Free-form per-test state, accepted here so that duplicate() // carries it through the one path that sanitizes everything. Meta // stores nothing a page renders, so a duplicate carries all of it. A // restarted split check belongs to the test it happened on: a new // test (a duplicate too) counts from its first visitor. $meta = self::sanitize_meta( self::without_own_meta( isset( $data['meta'] ) ? $data['meta'] : array() ) ); if ( is_wp_error( $meta ) ) { return $meta; } // The post each address of a URL test names now, so a change of // permalinks or of a page's address can be told later. if ( 'url' === $type ) { $urls = array(); foreach ( $variants_in as $i => $v ) { $urls[ $keys[ $i ] ] = isset( $v['url'] ) ? esc_url_raw( self::as_string( $v['url'] ) ) : ''; } $posts = self::url_posts( $urls ); if ( $posts ) { $meta[ self::URL_POSTS ] = $posts; } } $now = current_time( 'mysql', true ); $tfmt = array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ); $trow = array( 'uuid' => wp_generate_uuid4(), 'name' => $name, // Picked under the lock (insert_test()). 'slug' => '', 'description' => $description, 'type' => $type, 'status' => 'draft', 'goal_type' => $goal_type, 'goal_config' => wp_json_encode( $goal_cfg, JSON_UNESCAPED_SLASHES ), 'weights' => wp_json_encode( $weights, JSON_UNESCAPED_SLASHES ), 'targeting' => wp_json_encode( $targeting, JSON_UNESCAPED_SLASHES ), 'created_at' => $now, ); // Left out of the insert entirely when there is nothing to store, so // the column really does stay NULL on every test nothing writes to. if ( ! empty( $meta ) ) { $trow['meta'] = wp_json_encode( $meta, JSON_UNESCAPED_SLASHES ); $tfmt[] = '%s'; } // One create at a time from the name check to the commit: two // requests with one name (two tabs, a retry while the first was still // being served) both passed the check and read the same free slug, // then made two tests of one name or failed on the slug's unique key // with a 500 and a database error in the log. if ( ! self::hold_creates() ) { return self::creates_busy(); } try { return self::insert_test( $trow, $tfmt, $variants_in, $keys, $type, $want ); } finally { self::let_creates_go(); } } /** * The insert half of create(), run while this request holds the create * lock (hold_creates()): the name checked again and the slug picked, * then the test row and its variants in one transaction. * * @param array $trow Test row (its slug is set here). * @param array $tfmt Its formats. * @param array $variants_in Sanitized-on-insert variants, re-indexed. * @param array $keys Their keys. * @param string $type Test type. * @param string $want The slug the request asked for, or ''. * @return array|WP_Error */ private static function insert_test( $trow, $tfmt, $variants_in, $keys, $type, $want = '' ) { global $wpdb; if ( self::name_exists( $trow['name'] ) ) { return new WP_Error( 'improveab_dupe', __( 'A test with this name already exists. Please choose a different name.', 'improveab-ab-testing' ), array( 'status' => 409 ) ); } // Another slug than the one asked for would not be the classes the // owner may already have pasted. if ( '' !== $want && ! in_array( $want, self::RESERVED_SLUGS, true ) && self::slug_exists( $want ) ) { return new WP_Error( 'improveab_slug', __( 'Another test already uses the classes this name gives. Please choose a different name.', 'improveab-ab-testing' ), array( 'status' => 409 ) ); } $slug = self::unique_slug( $trow['name'], $want ); $trow['slug'] = $slug; // One transaction for the test row and all its variants: a failure // partway through rolls everything back, so a test can never exist // with missing variants. Mirrors the rollup() pattern; on a storage // engine without transactions these statements are no-ops and this // degrades to the previous behaviour. // // The id can be a deleted test's: MySQL 5.7 and older (and MariaDB // before 10.2.4) set the counter back to the highest id left when the // server restarts, and delete() removes a big test's events after its // row, in steps that can take several runs of the tally job. Rows // still under the id would be counted as the new test's, and its // visitors' first impressions refused as duplicates of the old ones, // so a test never takes an id that still holds events or tally // slots: that insert is rolled back and tried again. Those servers do // not hand a rolled-back id out a second time, so the next try takes // the next id; where the same id would come back, its rows are // cleared first. $test_id = 0; $clear_by = time() + 10; $trow['tallied_id'] = 0; $tfmt[] = '%d'; for ( $attempt = 0; $attempt < 5; $attempt++ ) { $wpdb->query( 'START TRANSACTION' ); // A new test starts with its tally's watermark at the newest // event: it has none of its own (see below), and a watermark of 0 // would hold the lowest one, from which a report on many tests // reads, at the start of the table until the job's next run. $trow['tallied_id'] = (int) $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}improveab_events" ); $ok = $wpdb->insert( $wpdb->prefix . 'improveab_tests', $trow, $tfmt ); if ( ! $ok ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'improveab_db', __( 'Could not save the test.', 'improveab-ab-testing' ), array( 'status' => 500 ) ); } $new = (int) $wpdb->insert_id; if ( ! self::id_holds_rows( $new ) ) { $test_id = $new; break; } $wpdb->query( 'ROLLBACK' ); self::delete_events( $new, $clear_by ); } if ( ! $test_id ) { update_option( 'improveab_orphans', 1, false ); return new WP_Error( 'improveab_busy', __( 'This site is still clearing the data of a deleted test. Please try again in a minute.', 'improveab-ab-testing' ), array( 'status' => 503 ) ); } // Variants, daily and tally rows a deletion that died half way left // under the id go before this test gets any. if ( false === $wpdb->delete( $wpdb->prefix . 'improveab_variants', array( 'test_id' => $test_id ), array( '%d' ) ) || false === $wpdb->delete( $wpdb->prefix . 'improveab_daily', array( 'test_id' => $test_id ), array( '%d' ) ) || false === $wpdb->delete( $wpdb->prefix . 'improveab_tally', array( 'test_id' => $test_id ), array( '%d' ) ) ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'improveab_db', __( 'Could not save the test.', 'improveab-ab-testing' ), array( 'status' => 500 ) ); } foreach ( $variants_in as $i => $v ) { $key = $keys[ $i ]; $label = isset( $v['label'] ) ? mb_substr( self::sanitize_words( $v['label'] ), 0, 191 ) : ''; if ( '' === $label ) { $label = self::default_label( $i ); } $class = 'improveab-' . $slug . '-' . $key; $url = isset( $v['url'] ) ? esc_url_raw( self::as_string( $v['url'] ) ) : ''; $vrow = array( 'test_id' => $test_id, 'variant_key' => $key, 'label' => $label, 'class_name' => $class, 'url' => $url, 'sort_order' => $i, ); $vfmt = array( '%d', '%s', '%s', '%s', '%s', '%d' ); if ( 'visual' === $type ) { $vrow['edits'] = wp_json_encode( self::sanitize_edits( isset( $v['edits'] ) ? $v['edits'] : array() ), JSON_UNESCAPED_SLASHES ); $vfmt[] = '%s'; } if ( ! $wpdb->insert( $wpdb->prefix . 'improveab_variants', $vrow, $vfmt ) ) { $wpdb->query( 'ROLLBACK' ); return new WP_Error( 'improveab_db', __( 'Could not save the test.', 'improveab-ab-testing' ), array( 'status' => 500 ) ); } } $wpdb->query( 'COMMIT' ); self::flush_cache(); return self::get( $test_id ); } /** * Take the create lock (an options row, ImproveAB_Install::lock()), * waiting up to ten seconds for another create, duplicate or rename to * finish. Re-entrant: duplicate() holds it across its name search and * the create() it calls. A request that dies holding it (a fatal error * skips the callers' finally) still lets it go at shutdown, rather than * leave every create waiting out the lock's two-minute takeover. * * @return bool Whether this request holds it. */ private static function hold_creates() { if ( self::$create_depth > 0 ) { ++self::$create_depth; return true; } $until = microtime( true ) + 10; while ( ! ImproveAB_Install::lock( self::CREATE_LOCK ) ) { if ( microtime( true ) >= $until ) { return false; } usleep( 50000 ); } self::$create_depth = 1; static $guarded = false; if ( ! $guarded ) { $guarded = true; register_shutdown_function( static function () { if ( self::$create_depth > 0 ) { self::$create_depth = 0; ImproveAB_Install::unlock( self::CREATE_LOCK ); } } ); } return true; } /** * Release the create lock once its outermost holder is done. * * @return void */ private static function let_creates_go() { if ( self::$create_depth > 0 && 0 === --self::$create_depth ) { ImproveAB_Install::unlock( self::CREATE_LOCK ); } } /** * The answer when the create lock stayed held past the wait. * * @return WP_Error */ private static function creates_busy() { return new WP_Error( 'improveab_busy', __( 'Another test is being saved at this moment. Please try again.', 'improveab-ab-testing' ), array( 'status' => 503 ) ); } /** * Whether a test id still holds rows a deleted test left: events or * tally slots, which delete() clears in steps. One index probe each. * * @param int $test_id Test ID. * @return bool */ private static function id_holds_rows( $test_id ) { global $wpdb; if ( $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}improveab_events WHERE test_id = %d LIMIT 1", $test_id ) ) ) { return true; } return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}improveab_buckets WHERE test_id = %d LIMIT 1", $test_id ) ); } /** * Update editable fields of a test. A rename holds the create lock, so * it cannot give a test the name another request is creating. * * @param int $id Test ID. * @param array $data Raw input. * @return array|WP_Error */ public static function update( $id, $data ) { if ( ! is_array( $data ) || ! isset( $data['name'] ) ) { return self::apply_update( $id, $data ); } if ( ! self::hold_creates() ) { return self::creates_busy(); } try { return self::apply_update( $id, $data ); } finally { self::let_creates_go(); } } /** * The body of update(). * * @param int $id Test ID. * @param array $data Raw input. * @return array|WP_Error */ private static function apply_update( $id, $data ) { global $wpdb; $test = self::get( $id ); if ( ! $test ) { return new WP_Error( 'improveab_not_found', self::gone_text(), array( 'status' => 404 ) ); } $fields = array(); $formats = array(); // Sanitize first, then test for emptiness (the order create() uses): // a name made only of tags is non-empty as typed but empty once // sanitized, and must be ignored rather than stored as ''. $new_name = isset( $data['name'] ) ? mb_substr( self::sanitize_words( $data['name'] ), 0, 191 ) : ''; if ( '' !== trim( $new_name ) ) { if ( self::name_exists( $new_name, absint( $id ) ) ) { return new WP_Error( 'improveab_dupe', __( 'A test with this name already exists. Please choose a different name.', 'improveab-ab-testing' ), array( 'status' => 409 ) ); } $fields['name'] = $new_name; $formats[] = '%s'; } if ( isset( $data['description'] ) ) { $fields['description'] = mb_substr( self::sanitize_words( $data['description'], true ), 0, 280 ); $formats[] = '%s'; } if ( isset( $data['notes'] ) ) { $fields['notes'] = mb_substr( self::sanitize_words( $data['notes'], true ), 0, 5000 ); $formats[] = '%s'; } $meta = null; if ( isset( $data['meta'] ) ) { // Merged by top-level key rather than replaced, so one feature can // update its own slice without having to know - or preserve - // whatever else keeps state on the same test. An explicit null // removes a key. $test is already hydrated, so the merge costs no // extra query. Stored below; the plugin's own records in it stay // as they are, as a caller's meta never sets one. $meta = self::sanitize_meta( self::without_own_meta( $data['meta'] ), isset( $test['meta'] ) ? $test['meta'] : array() ); if ( is_wp_error( $meta ) ) { return $meta; } } // The stored goal type stays valid even if the site has switched it // off since; only a change to another one is checked. if ( isset( $data['goal_type'] ) && $data['goal_type'] !== $test['goal_type'] && ! in_array( $data['goal_type'], improveab_goal_types(), true ) ) { return new WP_Error( 'improveab_goal_type', __( 'That goal type is not available on this site. Choose another goal.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } if ( isset( $data['goal_type'] ) && in_array( $data['goal_type'], self::GOAL_TYPES, true ) ) { $goal_type = $data['goal_type']; $cfg = self::sanitize_goal( $goal_type, isset( $data['goal_config'] ) ? (array) $data['goal_config'] : array() ); $fields['goal_type'] = $goal_type; $formats[] = '%s'; $fields['goal_config'] = wp_json_encode( $cfg, JSON_UNESCAPED_SLASHES ); $formats[] = '%s'; } // Where the test runs (its page scope) is edited here. Keys of the // stored targeting this version does not edit are kept as stored. if ( isset( $data['targeting'] ) ) { $in = is_array( $data['targeting'] ) ? $data['targeting'] : array(); $stored = is_array( $test['targeting'] ) ? $test['targeting'] : array(); $stored['pages'] = self::sanitize_pages( isset( $in['pages'] ) ? $in['pages'] : array() ); $fields['targeting'] = wp_json_encode( $stored, JSON_UNESCAPED_SLASHES ); $formats[] = '%s'; } // "Keep showing the winner": only meaningful on an ended test with a // declared winner, but harmless to store earlier (it takes effect // once a winner exists). if ( isset( $data['serve_winner'] ) ) { $fields['serve_winner'] = empty( $data['serve_winner'] ) ? 0 : 1; $formats[] = '%d'; // For a URL test it redirects every visitor to the winner's // address: to another site, that is an administrator's call. if ( 1 === $fields['serve_winner'] && empty( $test['serve_winner'] ) && self::serves_off_site( $test, (string) $test['winner_variant'] ) && ! self::may_set_admin_rules() ) { return self::offsite_error(); } } // A draft URL test's addresses as saved: the post each one on this // site names now (moved_addresses() compares later). if ( 'url' === $test['type'] && 'draft' === $test['status'] && isset( $data['variants'] ) && is_array( $data['variants'] ) ) { $urls = array(); foreach ( $test['variants'] as $tv ) { $urls[ (string) $tv['variant_key'] ] = (string) $tv['url']; } $sent = false; foreach ( $data['variants'] as $v ) { $vkey = is_array( $v ) && isset( $v['variant_key'] ) ? sanitize_text_field( self::as_string( $v['variant_key'] ) ) : ''; if ( '' !== $vkey && isset( $v['url'] ) && array_key_exists( $vkey, $urls ) ) { $urls[ $vkey ] = esc_url_raw( self::as_string( $v['url'] ) ); $sent = true; } } if ( $sent ) { $meta = is_array( $meta ) ? $meta : ( is_array( $test['meta'] ) ? $test['meta'] : array() ); $posts = self::url_posts( $urls ); if ( $posts ) { $meta[ self::URL_POSTS ] = $posts; } else { unset( $meta[ self::URL_POSTS ] ); } } } if ( null !== $meta ) { $fields['meta'] = wp_json_encode( $meta, JSON_UNESCAPED_SLASHES ); $formats[] = '%s'; } // A draft URL test's addresses: another site is an administrator's // call, as on create(). if ( 'url' === $test['type'] && 'draft' === $test['status'] && isset( $data['variants'] ) && is_array( $data['variants'] ) && ! self::may_set_admin_rules() ) { foreach ( $data['variants'] as $v ) { if ( is_array( $v ) && isset( $v['url'] ) && self::off_site( esc_url_raw( self::as_string( $v['url'] ) ) ) ) { return self::offsite_error(); } } } // Validate variant edits before anything is written, so a bad request // never half-applies. Same rule as create(): a visual test needs its // selector, or the runtime never finds the element and the test // silently never runs - a confusing way to fail. if ( 'visual' === $test['type'] && 'draft' === $test['status'] && isset( $data['variants'] ) && is_array( $data['variants'] ) ) { foreach ( $data['variants'] as $v ) { if ( ! is_array( $v ) || empty( $v['variant_key'] ) || ! isset( $v['edits'] ) ) { continue; } $e = self::sanitize_edits( (array) $v['edits'] ); if ( '' === $e['selector'] ) { return new WP_Error( 'improveab_selector', __( 'A visual test needs the selector of the element it changes.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } if ( 'default' !== self::as_string( $v['variant_key'] ) && ! self::changes_element( $e ) ) { return self::no_change_error(); } } } if ( ! empty( $fields ) ) { $wpdb->update( $wpdb->prefix . 'improveab_tests', $fields, array( 'id' => absint( $id ) ), $formats, array( '%d' ) ); } $variants_changed = false; if ( isset( $data['variants'] ) && is_array( $data['variants'] ) ) { foreach ( $data['variants'] as $v ) { if ( empty( $v['variant_key'] ) ) { continue; } $vfields = array(); $vformats = array(); // Sanitized before the emptiness check, for the same reason as // the test name above. $label = isset( $v['label'] ) ? mb_substr( self::sanitize_words( $v['label'] ), 0, 191 ) : ''; if ( '' !== $label ) { $vfields['label'] = $label; $vformats[] = '%s'; } // Variant URLs are structural: once a test has run, its // collected data belongs to those pages. URLs are therefore // only editable while the test is still a draft. if ( isset( $v['url'] ) && 'draft' === $test['status'] && 'url' === $test['type'] ) { $vfields['url'] = esc_url_raw( self::as_string( $v['url'] ) ); $vformats[] = '%s'; } // Visual edits (the element selector and the variant text) are // structural in the same way, so they follow the same rule. if ( isset( $v['edits'] ) && 'draft' === $test['status'] && 'visual' === $test['type'] ) { $edits = self::sanitize_edits( (array) $v['edits'] ); $edits = self::merge_stored_edits( $edits, $test, self::as_string( $v['variant_key'] ) ); $vfields['edits'] = wp_json_encode( $edits, JSON_UNESCAPED_SLASHES ); $vformats[] = '%s'; } if ( empty( $vfields ) ) { continue; } $variants_changed = $variants_changed || isset( $vfields['url'] ) || isset( $vfields['edits'] ); $wpdb->update( $wpdb->prefix . 'improveab_variants', $vfields, array( 'test_id' => absint( $id ), 'variant_key' => sanitize_text_field( self::as_string( $v['variant_key'] ) ), ), $vformats, array( '%d', '%s' ) ); } } self::flush_cache(); // Only a change to what pages print empties the page caches: saving // a test's notes purged every cached page on the site. if ( self::changes_pages( $test, $fields, $variants_changed ) ) { ImproveAB_Cache::purge_pages( 'test_update' ); } return self::get( $id ); } /** * Whether an update changes what pages print for this test. A running * test prints its rules (goal, page rules, variants), a * stopped test prints its winner while that is served, and a paused * test or a draft prints none of them (an idle element test's class * names never change after create). No page carries a name, a * description, notes, labels or meta. * * @param array $test The test as it was before the update. * @param array $fields The columns the update wrote. * @param bool $variants_changed Whether a variant's address or edits were written. * @return bool */ private static function changes_pages( $test, $fields, $variants_changed ) { $won = 'stopped' === $test['status'] && ! empty( $test['winner_variant'] ); // Keep showing the winner switched on or off: the test joins or // leaves every page. if ( $won && isset( $fields['serve_winner'] ) && (int) $fields['serve_winner'] !== (int) $test['serve_winner'] ) { return true; } if ( 'running' !== $test['status'] && ! ( $won && ! empty( $test['serve_winner'] ) ) ) { return false; } if ( $variants_changed ) { return true; } $before = array( 'goal_type' => (string) $test['goal_type'], 'goal_config' => (string) wp_json_encode( $test['goal_config'], JSON_UNESCAPED_SLASHES ), 'targeting' => (string) wp_json_encode( $test['targeting'], JSON_UNESCAPED_SLASHES ), ); foreach ( $before as $col => $was ) { if ( isset( $fields[ $col ] ) && (string) $fields[ $col ] !== $was ) { return true; } } return false; } /** * Change test status: start, stop, or declare a winner. * * @param int $id Test ID. * @param string $action One of start|pause|stop|winner. * @param string $winner Variant key when declaring a winner. * @param array $data { reason, serve_winner }: why (for the event), * and whether a declared winner is kept showing. * @return array|WP_Error A 409 `improveab_conflict` when another request * changed the test's status in the meantime. */ public static function set_status( $id, $action, $winner = '', $data = array() ) { $test = self::get( $id ); if ( ! $test ) { return new WP_Error( 'improveab_not_found', self::gone_text(), array( 'status' => 404 ) ); } $now = current_time( 'mysql', true ); if ( 'start' === $action ) { if ( 'stopped' === $test['status'] ) { return new WP_Error( 'improveab_stopped', __( 'This test was stopped and cannot be resumed. Duplicate it to run again.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } // Already running: what was asked for is so, and nothing is done. // A second Start (a double click, a retry after a lost reply, a // stale tab) used to be a fresh start: test_started fired again, // so whatever listens to it heard one start twice. if ( 'running' === $test['status'] ) { return $test; } $max_active = improveab_max_active_tests(); if ( $max_active > 0 && count( self::running_with_variants() ) >= $max_active ) { /* translators: %d: maximum number of concurrent tests configured for this site. */ return new WP_Error( 'improveab_max_active', sprintf( _n( 'This site runs up to %d test at once. Pause or stop the running test first.', 'This site runs up to %d tests at once. Pause or stop another running test first.', $max_active, 'improveab-ab-testing' ), $max_active ), array( 'status' => 403 ) ); } $fields = array( 'status' => 'running', 'started_at' => $test['started_at'] ? $test['started_at'] : $now, 'ended_at' => null, // A running test has no winner yet by definition; a stale // value would show a verdict the data no longer supports. 'winner_variant' => null, 'notified_winner' => 0, 'notified_srm' => 0, ); $formats = array( '%s', '%s', '%s', '%s', '%d', '%d' ); $written = self::write_status( $id, $test['status'], $fields, $formats ); // Another Start got there first (a double click on Resume): the // test is running as asked, and that request did the rest. if ( is_wp_error( $written ) && 'improveab_conflict' === $written->get_error_code() ) { $conflict = $written->get_error_data(); if ( isset( $conflict['test']['status'] ) && 'running' === $conflict['test']['status'] ) { return $conflict['test']; } } // A start clears the schedule stored with the test // (ImproveAB_Install::consume_schedule()): a start time, and an // end time that has already passed, belong to the run they were // set for. if ( ! is_wp_error( $written ) ) { ImproveAB_Install::consume_schedule( $id ); } } elseif ( 'pause' === $action ) { // "stopped" is terminal. Pausing a stopped test would turn it // into a paused one, which start accepts - resurrecting old data // under a fresh run. if ( 'running' !== $test['status'] ) { return new WP_Error( 'improveab_action', self::refusal_text( $test['status'], $action ), array( 'status' => 400 ) ); } $written = self::write_status( $id, $test['status'], array( 'status' => 'paused' ), array( '%s' ) ); } elseif ( 'stop' === $action ) { // Only something that ran can end: a stopped draft would carry an // ended_at without a started_at. if ( ! in_array( $test['status'], array( 'running', 'paused' ), true ) ) { return new WP_Error( 'improveab_action', self::refusal_text( $test['status'], $action ), array( 'status' => 400 ) ); } $written = self::write_status( $id, $test['status'], array( 'status' => 'stopped', 'ended_at' => $now ), array( '%s', '%s' ) ); } elseif ( 'winner' === $action ) { if ( ! in_array( $test['status'], array( 'running', 'paused' ), true ) ) { return new WP_Error( 'improveab_action', self::refusal_text( $test['status'], $action ), array( 'status' => 400 ) ); } $keys = wp_list_pluck( $test['variants'], 'variant_key' ); if ( ! in_array( $winner, $keys, true ) ) { return new WP_Error( 'improveab_winner', __( 'Unknown variant.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } // Declared while Keep showing the winner is on (or asked for), a // URL test's winner redirects every visitor at once: to another // site, that is an administrator's call (a declaration made with // no one signed in is the site's own: see may_set_admin_rules()). if ( ( ! empty( $test['serve_winner'] ) || ! empty( $data['serve_winner'] ) ) && self::serves_off_site( $test, $winner ) && ! self::may_set_admin_rules() ) { return self::offsite_error(); } $fields = array( 'status' => 'stopped', 'winner_variant' => sanitize_text_field( $winner ), 'ended_at' => $now ); $formats = array( '%s', '%s', '%s' ); // Keep showing the winner, when the caller asks for it with the // declaration, in the same write. if ( ! empty( $data['serve_winner'] ) ) { $fields['serve_winner'] = 1; $formats[] = '%d'; } $written = self::write_status( $id, $test['status'], $fields, $formats ); } else { return new WP_Error( 'improveab_action', __( 'Unknown action.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } if ( is_wp_error( $written ) ) { return $written; } self::flush_cache(); self::flush_data(); // Starting or stopping changes the head snippet on every page: cached // copies would keep serving (or withholding) the test until purged. ImproveAB_Cache::purge_pages( 'test_status' ); $updated = self::get( $id ); $events = array( 'start' => 'test_started', 'pause' => 'test_paused', 'stop' => 'test_stopped', 'winner' => 'winner_declared', ); /** * Fires after a test changed state, for any integration to hook. * * @param string $event Event name: test_started, test_paused, * test_stopped, winner_declared (other code * may fire more names on this action). * @param array $test Hydrated test after the change. * @param array $data Event details ({ previous_status, winner, * reason }). */ do_action( 'improveab_test_event', $events[ $action ], $updated, array( 'previous_status' => $test['status'], 'winner' => 'winner' === $action ? $winner : '', 'reason' => isset( $data['reason'] ) ? $data['reason'] : 'manual', ) ); return $updated; } /** * Whether the person making a change may set what reaches past this * site's own pages, such as a URL test's variant on another site, or * Keep showing the winner when that redirects every visitor there. A * role granted test management runs tests on the site's own pages; * these stay with administrators. A change made with no one signed in * (WP-CLI, for one) is the site's own: the REST routes always have a * user. * * @return bool */ private static function may_set_admin_rules() { return current_user_can( 'manage_options' ) || 0 === get_current_user_id(); } /** * Whether an address sends a visitor off this site: an absolute address * whose host is neither this site's nor inside a domain the site trusts * for cross-domain handoffs (its own registrable domain, plus what * `improveab_handoff_hosts` adds; the same rule as the runtime's). * A path on this site is never off it. * * @param string $url Address, already through esc_url_raw(). * @return bool */ private static function off_site( $url ) { $host = strtolower( rtrim( (string) wp_parse_url( (string) $url, PHP_URL_HOST ), '.' ) ); if ( '' === $host ) { return false; } // An international name compares in the form a browser names it, // whichever way each side was typed. $own = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $trusted = array_merge( array( $own ), (array) apply_filters( 'improveab_handoff_hosts', array( improveab_root_domain() ) ) ); return ! improveab_host_within( $host, $trusted ); } /** * Whether serving a URL test's winner sends visitors off the site: the * winner's address, or with none declared yet, any variant's. * * @param array $test The stored test. * @param string $winner Winner's variant key, '' when none yet. * @return bool */ private static function serves_off_site( $test, $winner ) { if ( 'url' !== $test['type'] ) { return false; } foreach ( (array) $test['variants'] as $v ) { if ( ( '' === $winner || $winner === $v['variant_key'] ) && self::off_site( isset( $v['url'] ) ? (string) $v['url'] : '' ) ) { return true; } } return false; } /** * The refusal for an address on another site. * * @return WP_Error */ private static function offsite_error() { return new WP_Error( 'improveab_offsite', __( 'Only an administrator can send visitors to another site. Use an address on this site, or ask an administrator.', 'improveab-ab-testing' ), array( 'status' => 403 ) ); } /** * Write a status change only if the test is still in the status it was * read in. set_status() reads, decides and then writes; another request * (a second admin, a double click) can change the test in between, and * an unconditional write went over it: a Stop and a Resume sent together * left a stopped test running. The database decides who was first. * * @param int $id Test ID. * @param string $from The status the decision was made on. * @param array $fields Columns to write. * @param array $formats Their formats. * @return true|WP_Error A 409 with the test as it is now when it moved. */ private static function write_status( $id, $from, $fields, $formats ) { global $wpdb; $rows = $wpdb->update( $wpdb->prefix . 'improveab_tests', $fields, array( 'id' => absint( $id ), 'status' => (string) $from ), $formats, array( '%d', '%s' ) ); if ( false === $rows ) { return new WP_Error( 'improveab_db', __( 'Could not save the test.', 'improveab-ab-testing' ), array( 'status' => 500 ) ); } if ( $rows > 0 ) { return true; } // MySQL counts the rows it changed, not the ones it matched: a write // whose every value is already stored reports 0. Its status tells // that apart from a lost race. $status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->prefix}improveab_tests WHERE id = %d", absint( $id ) ) ); if ( null !== $status && (string) $from === (string) $status ) { return true; } self::flush_cache(); $current = self::get( $id ); return new WP_Error( 'improveab_conflict', null === $current ? self::gone_text() : __( 'This test changed on another screen a moment ago, so nothing was done. Its page shows where it is now; try again from there if you still want to.', 'improveab-ab-testing' ), array( 'status' => null === $current ? 404 : 409, 'test' => $current, ) ); } /** * Why a status change cannot be made from the status the test is in. * The test has usually changed on another screen (another * administrator, a reply that never arrived), so the words name where * it is now rather than restating the rule. * * @param string $status Status the test is in. * @param string $action pause, stop or winner. * @return string */ private static function refusal_text( $status, $action ) { if ( 'stopped' === $status ) { return 'winner' === $action ? __( 'This test has already stopped, so a winner can no longer be declared. It may have been stopped on another screen.', 'improveab-ab-testing' ) : __( 'This test has already stopped. It may have been stopped on another screen.', 'improveab-ab-testing' ); } if ( 'paused' === $status && 'pause' === $action ) { return __( 'This test is already paused.', 'improveab-ab-testing' ); } if ( 'draft' === $status ) { return 'winner' === $action ? __( 'This test has not started yet, so it has no winner to declare.', 'improveab-ab-testing' ) : __( 'This test has not started yet.', 'improveab-ab-testing' ); } return 'pause' === $action ? __( 'Only a running test can be paused.', 'improveab-ab-testing' ) : __( 'Only a running or paused test can be stopped.', 'improveab-ab-testing' ); } /** * What a request for a test that is not there says: usually it was * deleted on another screen while this one still showed it. * * @return string */ public static function gone_text() { return __( 'This test no longer exists. It may have been deleted on another screen.', 'improveab-ab-testing' ); } /** * Delete a test and all related data. * * @param int $id Test ID. * @return bool */ public static function delete( $id ) { global $wpdb; $id = absint( $id ); $test = self::get( $id ); if ( $test && ( in_array( $test['status'], array( 'running', 'paused' ), true ) || ! empty( $test['serve_winner'] ) ) ) { ImproveAB_Cache::purge_pages( 'test_delete' ); } // The test itself first, so it stops being served and listed even if // the request ends early; a failure further down then leaves only // rows nothing reads, never a test without its variants. $deleted = (bool) $wpdb->delete( $wpdb->prefix . 'improveab_tests', array( 'id' => $id ), array( '%d' ) ); $wpdb->delete( $wpdb->prefix . 'improveab_variants', array( 'test_id' => $id ), array( '%d' ) ); $wpdb->delete( $wpdb->prefix . 'improveab_daily', array( 'test_id' => $id ), array( '%d' ) ); $wpdb->delete( $wpdb->prefix . 'improveab_tally', array( 'test_id' => $id ), array( '%d' ) ); self::flush_cache(); self::flush_data(); // The events and the tally's slots last and in steps: one DELETE of // a big test's millions of rows held its locks for the whole // statement and could outlast the request. Whatever the time allowed // here leaves behind, the tally job clears in its next runs and the // nightly rollup after that. if ( ! self::delete_events( $id, time() + 10 ) ) { update_option( 'improveab_orphans', 1, false ); } return $deleted; } /** * Delete a test's raw events, then its tally slots, in bounded steps * until none are left or the deadline passes. * * The events go a stretch of an index at a time, each statement reaching * its own stretch directly: by id along (test_id, id), so the rows are * also taken in the order they sit in the table. Deleting with "WHERE test_id = ... LIMIT n" walked again, every time, * past every row the statements before had deleted and the database had * not purged yet, so a test of tens of millions of rows took weeks of * nightly budgets. * * @param int $test_id Test ID. * @param int $deadline Unix time to stop at. * @return bool True when every row is gone. */ public static function delete_events( $test_id, $deadline ) { global $wpdb; $test_id = absint( $test_id ); $chunk = 20000; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table. $from = 0; $deleted = false; while ( true ) { if ( time() >= $deadline ) { if ( $deleted ) { ImproveAB_Install::clamp_watermarks(); } return false; } // The stretch's last id, walked along (test_id, id): named, as in // the tally job, because MySQL 8 otherwise sorts every remaining // row of the test for each stretch. $to = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}improveab_events USE INDEX (test_tail) WHERE test_id = %d AND id > %d ORDER BY test_id ASC, id ASC LIMIT 1 OFFSET %d", $test_id, $from, $chunk - 1 ) ); $n = null === $to ? $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND id > %d", $test_id, $from ) ) : $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND id > %d AND id <= %d", $test_id, $from, $to ) ); if ( false === $n ) { if ( $deleted ) { ImproveAB_Install::clamp_watermarks(); } return false; } $deleted = $deleted || $n > 0; if ( null === $to ) { break; } $from = (int) $to; } // The test's events can have been the newest: no watermark may stay // above the newest id left, which a server restart hands out again. if ( $deleted ) { ImproveAB_Install::clamp_watermarks(); } do { if ( time() >= $deadline ) { return false; } $n = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}improveab_buckets WHERE test_id = %d LIMIT %d", $test_id, $chunk ) ); if ( false === $n ) { return false; } } while ( $n >= $chunk ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return true; } /** * Duplicate a test as a new draft (new slug, new classes, no data). * * @param int $id Source test ID. * @return array|WP_Error */ public static function duplicate( $id ) { $src = self::get( $id ); if ( ! $src ) { return new WP_Error( 'improveab_not_found', self::gone_text(), array( 'status' => 404 ) ); } $suffix = ' ' . __( '(copy)', 'improveab-ab-testing' ); /* translators: 1: base name, 2: counter. */ $counted = __( '%1$s %2$d', 'improveab-ab-testing' ); // create() clamps names to 191 characters and only then checks for a // duplicate, so a long source name plus the suffix would clamp back // to the source name and every copy would be a "duplicate". Leave // room for the suffix and the counter. $base = self::fit_name( $src['name'], mb_strlen( $suffix ) + mb_strlen( sprintf( $counted, '', 9999 ) ) ) . $suffix; // The name is searched and taken under the create lock, so copies // made at the same moment (two tabs) each get a name of their own // instead of one copy and a refusal, or a 500. if ( ! self::hold_creates() ) { return self::creates_busy(); } try { return self::create_copy( $src, $base, $counted ); } finally { self::let_creates_go(); } } /** * The copy duplicate() makes, under the create lock. * * @param array $src Source test. * @param string $base The copy's name before a counter. * @param string $counted The name-and-counter pattern. * @return array|WP_Error */ private static function create_copy( $src, $base, $counted ) { $name = $base; $i = 2; while ( self::name_exists( $name ) ) { $name = sprintf( $counted, $base, $i ); $i++; } $variants = self::copy_variants( $src['variants'] ); return self::create( array( 'name' => $name, 'description' => isset( $src['description'] ) ? $src['description'] : '', 'type' => $src['type'], 'variants' => $variants, 'goal_type' => $src['goal_type'], 'goal_config' => $src['goal_config'], 'targeting' => isset( $src['targeting'] ) ? $src['targeting'] : array(), // A duplicate is the same test set up again, so whatever a // feature configured on it comes along; create() re-sanitizes // it, so a copy can carry nothing a hand-made test could not. 'meta' => isset( $src['meta'] ) ? $src['meta'] : array(), ), true ); } /** * A version's new edits merged onto its stored edits. This version * edits a version's selector, words, markup and page; keys of the * stored edits it does not edit are kept as stored. * * @param array $edits New edits, sanitized. * @param array $test The stored test. * @param string $key Variant key. * @return array */ private static function merge_stored_edits( $edits, $test, $key ) { foreach ( isset( $test['variants'] ) ? (array) $test['variants'] : array() as $row ) { if ( ! is_array( $row ) || ! isset( $row['variant_key'] ) || $key !== $row['variant_key'] || empty( $row['edits'] ) || ! is_array( $row['edits'] ) ) { continue; } foreach ( $row['edits'] as $k => $val ) { if ( ! array_key_exists( $k, $edits ) && ! in_array( $k, array( 'selector', 'text', 'mode', 'html', 'page' ), true ) ) { $edits[ $k ] = $val; } } } return $edits; } /** * Turn stored variant rows into create() input for a copy: labels, URLs * and edits. Used by duplicate(). * * @param array $rows Variant rows ({ label, url, edits }). * @return array */ private static function copy_variants( $rows ) { $variants = array(); foreach ( (array) $rows as $v ) { $edits = is_array( $v ) && isset( $v['edits'] ) && is_array( $v['edits'] ) ? $v['edits'] : null; $variants[] = array( 'label' => is_array( $v ) && isset( $v['label'] ) ? $v['label'] : '', 'url' => is_array( $v ) && isset( $v['url'] ) ? $v['url'] : '', 'edits' => $edits, ); } return $variants; } /** * Impressions, conversions and revenue per variant: the tally, the events * past it and the daily rollup, read together (compute_results()). * * @param int $id Test ID. * @param string $segment Optional segment (device) to filter by. Empty = all. * @return array Map keyed by variant_key. */ public static function results( $id, $segment = '' ) { $segment = self::device_segment( $segment ); if ( null === $segment ) { return array(); } ImproveAB_Install::catch_up(); // Every device class is counted in the same pass and kept together, // so switching the results page between devices reads nothing new. $all = self::remember( 'results_' . absint( $id ), static function () use ( $id ) { return self::compute_results( $id ); }, static function () use ( $id ) { return self::newest_event( $id ); } ); return ( is_array( $all ) && isset( $all[ $segment ] ) && is_array( $all[ $segment ] ) ) ? $all[ $segment ] : array(); } /** * A requested segment as the tables can hold it: '' for every device, * or one of the three device classes /event stores and the rollup keeps * a row for. Anything else is null: no row carries it, so a reader * answers it as empty and never builds a statement from it. * * @param mixed $segment Requested segment. * @return string|null */ public static function device_segment( $segment ) { $segment = is_scalar( $segment ) ? sanitize_text_field( (string) $segment ) : ''; return ( '' === $segment || in_array( $segment, array( 'desktop', 'mobile', 'tablet' ), true ) ) ? $segment : null; } /** * The newest event id of a test (through KEY test_tail), or of every * test (the primary key): what a kept aggregate is stamped with * (remember()). * * @param int $id Test ID, or 0 for every test. * @return int */ private static function newest_event( $id = 0 ) { global $wpdb; if ( $id ) { return (int) $wpdb->get_var( $wpdb->prepare( "SELECT MAX(id) FROM {$wpdb->prefix}improveab_events WHERE test_id = %d", absint( $id ) ) ); } return (int) $wpdb->get_var( "SELECT MAX(id) FROM {$wpdb->prefix}improveab_events" ); } /** * Forget a test's kept results (remember()), after a change that adds * no event: a refund taken off a conversion's revenue. * * @param int $id Test ID. * @return void */ public static function forget_results( $id ) { $ckey = 'improveab_agg_' . md5( 'results_' . absint( $id ) ); wp_cache_delete( $ckey, self::CACHE_GROUP ); if ( ! wp_using_ext_object_cache() ) { delete_transient( $ckey ); } } /** * An admin aggregate, served from a short cache only when computing it * was slow. A small test's numbers are always read fresh (and always * match the events a moment ago); a slow one is kept for a while * proportional to what it cost (a second's read is kept two minutes, * never more than fifteen), tagged with the data generation * (flush_data()), so a rename or a note leaves it in place. * * Once its time is up, one request computes it again while every other * request serves the kept value (a lock row, or an object-cache add), * so a busy dashboard does not send the same slow read several times at * once while nothing new has been recorded. A value from an older * generation is never served. * * Nor is one computed before the newest event there is now: the kept * copy carries the newest event id it was computed at ($stamp, one * indexed read) and is good only while that is still the newest, so a * page never shows the tiles and cards from a kept copy beside a chart * or a list read afresh, two moments that disagree. A new visitor reads * it afresh at once, and so does every request until a copy is kept * again, each on its own: none waits for another's read, so where a * read is slow and visitors keep arriving, requests that come together * each read it. * * @param string $key Cache key. * @param callable $compute Produces the value. * @param callable|null $stamp The newest event id of what is counted. * @return mixed */ private static function remember( $key, $compute, $stamp = null ) { global $wpdb; $gen = self::data_gen(); $ckey = 'improveab_agg_' . md5( $key ); $ext = wp_using_ext_object_cache(); $saved = wp_cache_get( $ckey, self::CACHE_GROUP ); if ( false === $saved && ! $ext ) { $saved = get_transient( $ckey ); } // Read before computing: an event that arrives meanwhile is newer // than the stamp kept, so the copy is read again next time. $mark = null === $stamp ? '' : (string) call_user_func( $stamp ); $kept = is_array( $saved ) && isset( $saved['g'], $saved['f'] ) && array_key_exists( 'v', $saved ) && $gen === $saved['g'] && $mark === ( isset( $saved['m'] ) ? (string) $saved['m'] : '' ); if ( $kept && time() < (int) $saved['f'] ) { return $saved['v']; } $lock = ''; if ( $kept ) { $lock = $ckey . '_lock'; $mine = $ext ? wp_cache_add( $lock, 1, self::CACHE_GROUP, MINUTE_IN_SECONDS ) : ImproveAB_Install::lock( 'improveab_agg_lock_' . md5( $key ) ); if ( ! $mine ) { return $saved['v']; } } $start = microtime( true ); $value = call_user_func( $compute ); $took = microtime( true ) - $start; if ( $took >= 0.5 && '' === $wpdb->last_error ) { $fresh = (int) min( 15 * MINUTE_IN_SECONDS, max( MINUTE_IN_SECONDS, ceil( $took * 120 ) ) ); $saved = array( 'g' => $gen, 'm' => $mark, 'f' => time() + $fresh, 'v' => $value, ); // Kept twice as long as it is fresh: the second half is what the // other requests serve while one computes it again. wp_cache_set( $ckey, $saved, self::CACHE_GROUP, 2 * $fresh ); if ( ! $ext ) { set_transient( $ckey, $saved, 2 * $fresh ); } } elseif ( $kept ) { // Quick now (the tally caught up): read fresh from here on. wp_cache_delete( $ckey, self::CACHE_GROUP ); if ( ! $ext ) { delete_transient( $ckey ); } } if ( '' !== $lock ) { if ( $ext ) { wp_cache_delete( $lock, self::CACHE_GROUP ); } else { ImproveAB_Install::unlock( 'improveab_agg_lock_' . md5( $key ) ); } } return $value; } /** * The uncached aggregate behind results(), for every device at once: a * map of '' (every device) and each device class to the variant rows. * * @param int $id Test ID. * @return array */ private static function compute_results( $id ) { global $wpdb; $id = absint( $id ); $out = array( '' => array(), 'desktop' => array(), 'mobile' => array(), 'tablet' => array(), ); // The tally, the events past the test's watermark and the rolled-up // days in one statement, so all three are read from one snapshot: // read one after the other, the tally job (or a nightly rollup) // moving a window in between had its events counted twice or not at // all. The watermark is read in the same statement, by the join. // Tally and daily rows are already split as every device ('') and // each device class beside it; an event is added to '' and to its // own class. metric '' is the test's primary goal: pinning it is // what lets a later per-step row sit beside it without inflating a // number (CONTRACTS §2). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT 0 AS raw, variant_key, segment, SUM(impressions) AS impressions, SUM(conversions) AS conversions, SUM(revenue) AS revenue FROM {$wpdb->prefix}improveab_tally WHERE test_id = %d AND metric = '' GROUP BY variant_key, segment UNION ALL SELECT 1 AS raw, e.variant_key, e.segment, SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions, SUM(CASE WHEN e.event_type = 'conversion' THEN 1 ELSE 0 END) AS conversions, SUM(CASE WHEN e.event_type = 'conversion' THEN e.value ELSE 0 END) AS revenue FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id > t.tallied_id WHERE t.id = %d GROUP BY e.variant_key, e.segment UNION ALL SELECT 0 AS raw, variant_key, segment, SUM(impressions) AS impressions, SUM(conversions) AS conversions, SUM(revenue) AS revenue FROM {$wpdb->prefix}improveab_daily WHERE test_id = %d AND metric = '' GROUP BY variant_key, segment", $id, $id, $id ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { $rows = null; } if ( null === $rows ) { // The tally could not be read (a table that went missing, until // the plugin's screens put it back): every event instead. foreach ( array_keys( $out ) as $seg ) { $out[ $seg ] = self::results_from_events( $id, $seg ); } return $out; } foreach ( (array) $rows as $r ) { $seg = (string) $r['segment']; if ( (int) $r['raw'] ) { $targets = in_array( $seg, array( 'desktop', 'mobile', 'tablet' ), true ) ? array( '', $seg ) : array( '' ); } else { $targets = isset( $out[ $seg ] ) ? array( $seg ) : array(); } $k = (string) $r['variant_key']; foreach ( $targets as $t ) { if ( ! isset( $out[ $t ][ $k ] ) ) { $out[ $t ][ $k ] = array( 'impressions' => 0, 'conversions' => 0, 'revenue' => 0.0 ); } $out[ $t ][ $k ]['impressions'] += (int) $r['impressions']; $out[ $t ][ $k ]['conversions'] += (int) $r['conversions']; $out[ $t ][ $k ]['revenue'] += (float) $r['revenue']; } } foreach ( $out as $t => $map ) { foreach ( $map as $k => $row ) { $out[ $t ][ $k ]['revenue'] = round( $row['revenue'], 2 ); } } return $out; } /** * One segment's results from every event and the rolled-up days, for a * site whose tally cannot be read. * * @param int $id Test ID. * @param string $segment Device class or ''. * @return array */ private static function results_from_events( $id, $segment ) { global $wpdb; $totals = array(); // The rolled-up days are read with the test's primary goal pinned // (metric ''), as every reader of that table does (CONTRACTS §2). if ( '' !== $segment ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT variant_key, SUM(CASE WHEN event_type='impression' THEN 1 ELSE 0 END) AS impressions, SUM(CASE WHEN event_type='conversion' THEN 1 ELSE 0 END) AS conversions, SUM(CASE WHEN event_type='conversion' THEN value ELSE 0 END) AS revenue FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND segment = %s GROUP BY variant_key UNION ALL SELECT variant_key, SUM(impressions) AS impressions, SUM(conversions) AS conversions, SUM(revenue) AS revenue FROM {$wpdb->prefix}improveab_daily WHERE test_id = %d AND metric = '' AND segment = %s GROUP BY variant_key", $id, $segment, $id, $segment ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT variant_key, SUM(CASE WHEN event_type='impression' THEN 1 ELSE 0 END) AS impressions, SUM(CASE WHEN event_type='conversion' THEN 1 ELSE 0 END) AS conversions, SUM(CASE WHEN event_type='conversion' THEN value ELSE 0 END) AS revenue FROM {$wpdb->prefix}improveab_events WHERE test_id = %d GROUP BY variant_key UNION ALL SELECT variant_key, SUM(impressions) AS impressions, SUM(conversions) AS conversions, SUM(revenue) AS revenue FROM {$wpdb->prefix}improveab_daily WHERE test_id = %d AND metric = '' AND segment = '' GROUP BY variant_key", $id, $id ), ARRAY_A ); } $rows = '' === $wpdb->last_error ? (array) $rows : array(); foreach ( $rows as $r ) { $k = $r['variant_key']; if ( ! isset( $totals[ $k ] ) ) { $totals[ $k ] = array( 'impressions' => 0, 'conversions' => 0, 'revenue' => 0.0 ); } $totals[ $k ]['impressions'] += (int) $r['impressions']; $totals[ $k ]['conversions'] += (int) $r['conversions']; $totals[ $k ]['revenue'] += (float) $r['revenue']; } return $totals; } /** * Per-day impressions and conversions per variant, merged from live events * and the daily rollup. Used to chart performance over time. * * A live event is counted on the day it happened in the site's time zone * (Settings › General). A day the nightly rollup compacted, which only * happens to a stopped test's events past the retention period, is the * UTC day `daily.day` has always held, and its row says so in `utc`. * * @param int $id Test ID. * @return array List of rows: { day, variant_key, impressions, conversions, utc }. */ public static function timeseries( $id ) { $zone = wp_timezone(); ImproveAB_Install::catch_up(); return self::remember( // The days depend on the zone, so a series kept while the site was // in one zone is never served once Settings name another. 'timeseries_' . absint( $id ) . '_' . wp_timezone_string(), static function () use ( $id, $zone ) { return self::compute_timeseries( $id, $zone ); }, static function () use ( $id ) { return self::newest_event( $id ); } ); } /** * The uncached aggregate behind timeseries(). * * @param int $id Test ID. * @param DateTimeZone $zone The site's time zone. * @return array */ private static function compute_timeseries( $id, $zone ) { global $wpdb; $id = absint( $id ); $map = array(); // The rows are grouped by their UTC date and by how many of the day's // cuts their time has passed (day_cuts(): every local midnight and // every clock change the rows' span holds, as UTC times of day). // No midnight and no change falls inside a group, so the group's // first instant names the local day of every row in it, exactly, // with plain string comparisons both MySQL and the SQLite drop-in // answer alike. A zone has few cuts (New York at most four, Kathmandu // one, UTC none), so a day stays a few rows where fixed quarter-hour // slots would make 96. The latest cut is tested first: in the // Americas most visits come after it. The span comes from the // (test_id, created_at) index; it counts nothing, so it can be read // on its own. $span = $wpdb->get_row( $wpdb->prepare( "SELECT (SELECT MIN(created_at) FROM {$wpdb->prefix}improveab_events WHERE test_id = %d) AS lo, (SELECT MAX(created_at) FROM {$wpdb->prefix}improveab_events WHERE test_id = %d) AS hi", $id, $id ), ARRAY_A ); $lo = isset( $span['lo'] ) ? (string) $span['lo'] : ''; $cuts = self::day_cuts( $zone, $lo, isset( $span['hi'] ) ? (string) $span['hi'] : '' ); // The tally's slots stand in for the events they summed: a slot is a // quarter hour, and so is every cut of every real zone, so a slot // lies on one side of each cut whole. A cut off the quarter hour (an // offset typed in by code, a historic zone) reads every event, as // before the tally. $quarters = true; foreach ( $cuts as $cut ) { if ( ! preg_match( '/^\d{2}:(00|15|30|45):00$/', $cut ) ) { $quarters = false; } } // One statement, for the reason compute_results() gives. The // rolled-up days come back with slot -1: they stay UTC days. With // cuts, the slot is the number of cuts the time of day has passed // (cut_list() fills the eight places). $rows = null; if ( ! $cuts ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT variant_key, DATE(bucket) AS day, 0 AS slot, SUM(impressions) AS impressions, SUM(conversions) AS conversions FROM {$wpdb->prefix}improveab_buckets WHERE test_id = %d AND metric = '' AND segment = '' GROUP BY variant_key, DATE(bucket) UNION ALL SELECT e.variant_key, DATE(e.created_at) AS day, 0 AS slot, SUM(CASE WHEN e.event_type='impression' THEN 1 ELSE 0 END) AS impressions, SUM(CASE WHEN e.event_type='conversion' THEN 1 ELSE 0 END) AS conversions FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id > t.tallied_id WHERE t.id = %d GROUP BY e.variant_key, DATE(e.created_at) UNION ALL SELECT variant_key, day, -1 AS slot, SUM(impressions) AS impressions, SUM(conversions) AS conversions FROM {$wpdb->prefix}improveab_daily WHERE test_id = %d AND metric = '' AND segment = '' GROUP BY variant_key, day", $id, $id, $id ), ARRAY_A ); } elseif ( $quarters && count( $cuts ) <= self::DAY_CUTS ) { $c = self::cut_list( $cuts ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT variant_key, DATE(bucket) AS day, CASE WHEN SUBSTR(bucket, 12, 8) >= %s THEN 8 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 7 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 6 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 5 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 4 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 3 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 2 WHEN SUBSTR(bucket, 12, 8) >= %s THEN 1 ELSE 0 END AS slot, SUM(impressions) AS impressions, SUM(conversions) AS conversions FROM {$wpdb->prefix}improveab_buckets WHERE test_id = %d AND metric = '' AND segment = '' GROUP BY variant_key, DATE(bucket), slot UNION ALL SELECT e.variant_key, DATE(e.created_at) AS day, CASE WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 8 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 7 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 6 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 5 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 4 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 3 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 2 WHEN SUBSTR(e.created_at, 12, 8) >= %s THEN 1 ELSE 0 END AS slot, SUM(CASE WHEN e.event_type='impression' THEN 1 ELSE 0 END) AS impressions, SUM(CASE WHEN e.event_type='conversion' THEN 1 ELSE 0 END) AS conversions FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id > t.tallied_id WHERE t.id = %d GROUP BY e.variant_key, DATE(e.created_at), slot UNION ALL SELECT variant_key, day, -1 AS slot, SUM(impressions) AS impressions, SUM(conversions) AS conversions FROM {$wpdb->prefix}improveab_daily WHERE test_id = %d AND metric = '' AND segment = '' GROUP BY variant_key, day", $c[0], $c[1], $c[2], $c[3], $c[4], $c[5], $c[6], $c[7], $id, $c[0], $c[1], $c[2], $c[3], $c[4], $c[5], $c[6], $c[7], $id, $id ), ARRAY_A ); } if ( null !== $rows && '' !== $wpdb->last_error ) { $rows = null; } if ( null !== $rows ) { $rows = self::cut_rows( (array) $rows, $cuts ); } else { // Every event and the rolled-up days, through the same cuts. Past // eight cuts (no zone has needed more since 2000, from any year to // now) the events are read a year at a time, each year with its // own cuts; a year holds at most five. $rows = array(); foreach ( self::cut_windows( $zone, $cuts, $lo ) as $n => $w ) { $c = self::cut_list( $w['cuts'] ); $got = $wpdb->get_results( $wpdb->prepare( "SELECT variant_key, DATE(created_at) AS day, CASE WHEN SUBSTR(created_at, 12, 8) >= %s THEN 8 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 7 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 6 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 5 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 4 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 3 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 2 WHEN SUBSTR(created_at, 12, 8) >= %s THEN 1 ELSE 0 END AS slot, SUM(CASE WHEN event_type='impression' THEN 1 ELSE 0 END) AS impressions, SUM(CASE WHEN event_type='conversion' THEN 1 ELSE 0 END) AS conversions FROM {$wpdb->prefix}improveab_events WHERE test_id = %d AND created_at >= %s AND created_at < %s GROUP BY variant_key, DATE(created_at), slot UNION ALL SELECT variant_key, day, -1 AS slot, SUM(impressions) AS impressions, SUM(conversions) AS conversions FROM {$wpdb->prefix}improveab_daily WHERE test_id = %d AND metric = '' AND segment = '' GROUP BY variant_key, day", $c[0], $c[1], $c[2], $c[3], $c[4], $c[5], $c[6], $c[7], $id, $w['from'], $w['to'], // The rolled-up days once, with the first window. 0 === $n ? $id : 0 ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { $rows = array(); break; } $rows = array_merge( $rows, self::cut_rows( (array) $got, $w['cuts'] ) ); } } $utc_zone = new DateTimeZone( 'UTC' ); $local = array(); foreach ( $rows as $r ) { $day = (string) $r['day']; $utc = null === $r['cut']; if ( ! $utc && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $day ) ) { $at = $day . ' ' . $r['cut']; if ( ! isset( $local[ $at ] ) ) { $moment = new DateTime( $at, $utc_zone ); $local[ $at ] = $moment->setTimezone( $zone )->format( 'Y-m-d' ); } $day = $local[ $at ]; } $key = $day . '|' . $r['variant_key']; if ( ! isset( $map[ $key ] ) ) { $map[ $key ] = array( 'day' => $day, 'variant_key' => $r['variant_key'], 'impressions' => 0, 'conversions' => 0, 'utc' => false, ); } $map[ $key ]['impressions'] += (int) $r['impressions']; $map[ $key ]['conversions'] += (int) $r['conversions']; $map[ $key ]['utc'] = $map[ $key ]['utc'] || $utc; } // Where the site's clock read UTC over every rolled-up day (a site on // UTC, or London all winter), a UTC day is the site's own day and the // chart has nothing to say about it. $utc_days = array(); foreach ( $map as $row ) { if ( $row['utc'] ) { $utc_days[] = $row['day']; } } if ( $utc_days && self::zone_reads_utc( $zone, min( $utc_days ), max( $utc_days ) ) ) { foreach ( $map as $key => $row ) { $map[ $key ]['utc'] = false; } } $rows = array_values( $map ); usort( $rows, function ( $a, $b ) { if ( $a['day'] === $b['day'] ) { return strcmp( $a['variant_key'], $b['variant_key'] ); } return strcmp( $a['day'], $b['day'] ); } ); return $rows; } /** * Whether the zone's offset from UTC is zero throughout two UTC days and * everything between them, so that each of those UTC days is also the * site's own day. * * @param DateTimeZone $zone The site's time zone. * @param string $from The first day, 'Y-m-d'. * @param string $to The last day, 'Y-m-d'. * @return bool */ private static function zone_reads_utc( $zone, $from, $to ) { $start = (int) strtotime( $from . ' 00:00:00 UTC' ); $end = (int) strtotime( $to . ' 23:59:59 UTC' ); $changes = $zone->getTransitions( $start, $end ); if ( ! is_array( $changes ) || ! $changes ) { // A manual offset (UTC+0 in Settings) has no clock changes. return 0 === (int) $zone->getOffset( new DateTime( '@' . $start ) ); } foreach ( $changes as $change ) { if ( 0 !== (int) $change['offset'] ) { return false; } } return true; } /** * The UTC times of day ('H:i:s', sorted, never midnight UTC itself) at * which the site's calendar day can turn: local midnight under every * offset the zone uses between two moments, and the time of each of the * zone's clock changes between them. Between two cuts on one UTC date * every moment has one offset and one local day. * * The span is widened by a day each way and runs at least to now, so a * visit recorded after the span was read still falls inside it. * * @param DateTimeZone $zone The site's time zone. * @param string $from The first live row's UTC 'Y-m-d H:i:s', or ''. * @param string $to The last live row's, or ''. * @return string[] */ private static function day_cuts( $zone, $from, $to ) { $start = ( '' !== $from ? (int) strtotime( $from . ' UTC' ) : time() ) - DAY_IN_SECONDS; $end = max( '' !== $to ? (int) strtotime( $to . ' UTC' ) : 0, time() ) + DAY_IN_SECONDS; return self::cuts_between( $zone, $start, $end ); } /** * The day cuts of a zone between two moments (day_cuts()). * * @param DateTimeZone $zone The site's time zone. * @param int $start Unix time. * @param int $end Unix time. * @return string[] UTC times of day, 'H:i:s', sorted. */ private static function cuts_between( $zone, $start, $end ) { $offsets = array(); $cuts = array(); $changes = $zone->getTransitions( $start, $end ); if ( is_array( $changes ) && $changes ) { // The first entry is the zone as it stood at $start; each later // one is a clock change. foreach ( array_values( $changes ) as $i => $change ) { $offsets[ (int) $change['offset'] ] = true; if ( $i > 0 ) { $cuts[ gmdate( 'H:i:s', (int) $change['ts'] ) ] = true; } } } else { // A manual offset (UTC+5:45 in Settings) has no clock changes. $offsets[ (int) $zone->getOffset( new DateTime( '@' . $start ) ) ] = true; } foreach ( array_keys( $offsets ) as $offset ) { $cuts[ gmdate( 'H:i:s', ( ( -$offset % DAY_IN_SECONDS ) + DAY_IN_SECONDS ) % DAY_IN_SECONDS ) ] = true; } unset( $cuts['00:00:00'] ); $cuts = array_keys( $cuts ); sort( $cuts ); return $cuts; } /** * The eight values a chart statement compares each time of day with: * the cuts, latest first (the first WHEN, THEN 8, names the latest), * then '24:00:00', which no time of day reaches. * * @param string[] $cuts Cuts, sorted (day_cuts()). * @return string[] Exactly DAY_CUTS values. */ private static function cut_list( $cuts ) { $list = array_slice( array_reverse( array_values( $cuts ) ), 0, self::DAY_CUTS ); return array_pad( $list, self::DAY_CUTS, '24:00:00' ); } /** * Name each chart row's first instant: the time of day of the latest cut * its slot has passed ('00:00:00' before the first), or null for a * rolled-up day (slot -1), which stays a UTC day. A slot counts down from * the latest cut (cut_list()), so THEN 8 is every cut passed. * * @param array $rows Rows with day, slot, variant_key and counts. * @param string[] $cuts The cuts the statement compared with. * @return array The rows, each with `cut`. */ private static function cut_rows( $rows, $cuts ) { $n = count( $cuts ); foreach ( $rows as $i => $r ) { $slot = (int) $r['slot']; $passed = $slot > 0 ? $slot - ( self::DAY_CUTS - $n ) : 0; if ( $slot < 0 ) { $rows[ $i ]['cut'] = null; } else { $rows[ $i ]['cut'] = $passed > 0 && isset( $cuts[ $passed - 1 ] ) ? $cuts[ $passed - 1 ] : '00:00:00'; } } return $rows; } /** * The stretches of time the chart reads events in, each with its own * cuts: the whole span while it has at most DAY_CUTS, else one calendar * year (UTC) at a time from the first event's year to now. * * @param DateTimeZone $zone The site's time zone. * @param string[] $cuts The span's cuts. * @param string $lo The first event's time, UTC, or ''. * @return array[] Each { from, to, cuts }, from inclusive, to exclusive. */ private static function cut_windows( $zone, $cuts, $lo ) { $first = '1000-01-01 00:00:00'; $last = '9999-12-31 23:59:59'; if ( count( $cuts ) <= self::DAY_CUTS ) { return array( array( 'from' => $first, 'to' => $last, 'cuts' => $cuts, ), ); } $out = array(); $from = '' !== $lo ? (int) substr( $lo, 0, 4 ) : (int) gmdate( 'Y' ); $to = max( $from, (int) gmdate( 'Y' ) ); for ( $y = $from; $y <= $to; $y++ ) { $out[] = array( 'from' => $y === $from ? $first : sprintf( '%04d-01-01 00:00:00', $y ), 'to' => $y === $to ? $last : sprintf( '%04d-01-01 00:00:00', $y + 1 ), 'cuts' => self::cuts_between( $zone, gmmktime( 0, 0, 0, 1, 1, $y ) - DAY_IN_SECONDS, gmmktime( 0, 0, 0, 1, 1, $y + 1 ) + DAY_IN_SECONDS ), ); } return $out; } /* --------------------------------------------------------------------- */ /* Helpers */ /* --------------------------------------------------------------------- */ /** * Count the traffic split check of every started test (running, paused * or stopped) from now on, after the code that assigns visitors was * replaced (a move between the editions; ImproveAB_Install::note_edition()). * The visitors counted so far were assigned by the code that ran before, * and holding them to the split this code plans could read "Split off" * for good: a stopped test most of all, as no visitor comes after to * set it right. The check counts from now; the counts and the test's * other data are left as they are. The rows themselves are not read: * the rule is the same for every test. A draft has no visitors yet. * * @return void */ public static function split_checks_from_now() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; no input in the statement. $rows = $wpdb->get_results( "SELECT id, meta FROM {$wpdb->prefix}improveab_tests WHERE status IN ('running','paused','stopped')", ARRAY_A ); $now = current_time( 'mysql', true ); $any = false; foreach ( (array) $rows as $row ) { $any = true; $meta = ! empty( $row['meta'] ) ? json_decode( $row['meta'], true ) : array(); $meta = is_array( $meta ) ? $meta : array(); $meta[ self::SPLIT_SINCE ] = $now; $fields = array( 'meta' => wp_json_encode( $meta, JSON_UNESCAPED_SLASHES ) ); $formats = array( '%s' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->update( $wpdb->prefix . 'improveab_tests', $fields, array( 'id' => absint( $row['id'] ) ), $formats, array( '%d' ) ); } if ( $any ) { self::flush_data(); } } /** * When a started test's traffic split check last started over (UTC * 'Y-m-d H:i:s'), or '' when it never did: the check counts from that * moment on. * * @param array $test A test. A row from the runtime sets carries no meta * (cached_set() leaves the column out) and is read by * its id. * @return string */ public static function split_since( $test ) { if ( ! is_array( $test ) ) { return ''; } if ( array_key_exists( 'meta', $test ) ) { $meta = $test['meta']; } else { $id = isset( $test['id'] ) ? absint( $test['id'] ) : 0; if ( ! $id ) { return ''; } global $wpdb; $raw = $wpdb->get_var( $wpdb->prepare( "SELECT meta FROM {$wpdb->prefix}improveab_tests WHERE id = %d", $id ) ); $meta = $raw ? json_decode( $raw, true ) : array(); } $since = ( is_array( $meta ) && isset( $meta[ self::SPLIT_SINCE ] ) && is_string( $meta[ self::SPLIT_SINCE ] ) ) ? $meta[ self::SPLIT_SINCE ] : ''; return preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $since ) ? $since : ''; } /** * The visitors counted per variant since the split check started over: * the impressions since that moment and the rolled-up days after its * own day, in one statement (see compute_results()). The rollup keeps * whole days, so that day counts only the rows it has not compacted * yet: a smaller sample of the same split, never a mix. * * @param int $id Test ID. * @param string $since UTC 'Y-m-d H:i:s', from split_since(). * @param string $segment Device class, or '' for every device. * @return array Map of variant_key => visitors. */ public static function split_counts( $id, $since, $segment = '' ) { $id = absint( $id ); $since = (string) $since; $segment = self::device_segment( $segment ); if ( null === $segment ) { return array(); } ImproveAB_Install::catch_up(); return self::remember( 'split_' . $id . '_' . $segment . '_' . $since, static function () use ( $id, $since, $segment ) { $rows = self::since_rows( array( $id ), $since, $segment ); $out = array(); foreach ( $rows as $r ) { $k = (string) $r['variant_key']; $out[ $k ] = ( isset( $out[ $k ] ) ? $out[ $k ] : 0 ) + (int) $r['imps']; } return $out; }, static function () use ( $id ) { return self::newest_event( $id ); } ); } /** * The visitors counted per test and variant from a moment on, as rows of * (test_id, variant_key, imps), for one test or several whose split * check restarted at the same moment (after a move between the * editions, every running, paused and stopped test did), in one * statement. With the tally: the fifteen-minute slots from the first * quarter hour after the moment, the summed events before that quarter * hour (on the (test_id, created_at) index), the events past the * watermark, and the rolled-up days after the moment's own. * * @param int[] $ids The IDs of tests that share $since. * @param string $since UTC 'Y-m-d H:i:s'. * @param string $segment Device class, or '' for every device. * @return array Rows; empty when the read failed ($wpdb->last_error says so). */ private static function since_rows( $ids, $since, $segment ) { global $wpdb; $ids = array_values( array_map( 'absint', (array) $ids ) ); $day = substr( $since, 0, 10 ); $edge = gmdate( 'Y-m-d H:i:s', ImproveAB_Install::quarter( (int) strtotime( $since . ' UTC' ) + 899 ) ); if ( '' !== $segment ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key, SUM(impressions) AS imps FROM {$wpdb->prefix}improveab_buckets WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND metric = '' AND segment = %s AND bucket >= %s GROUP BY test_id, variant_key UNION ALL SELECT t.id AS test_id, e.variant_key, COUNT(*) AS imps FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id <= t.tallied_id WHERE t.id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND e.event_type = 'impression' AND e.created_at >= %s AND e.created_at < %s AND e.segment = %s GROUP BY t.id, e.variant_key UNION ALL SELECT t.id AS test_id, e.variant_key, COUNT(*) AS imps FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id > t.tallied_id WHERE t.id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND e.event_type = 'impression' AND e.created_at >= %s AND e.segment = %s GROUP BY t.id, e.variant_key UNION ALL SELECT test_id, variant_key, SUM(impressions) AS imps FROM {$wpdb->prefix}improveab_daily WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND metric = '' AND segment = %s AND day > %s GROUP BY test_id, variant_key", array_merge( $ids, array( $segment, $edge ), $ids, array( $since, $edge, $segment ), $ids, array( $since, $segment ), $ids, array( $segment, $day ) ) ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key, SUM(impressions) AS imps FROM {$wpdb->prefix}improveab_buckets WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND metric = '' AND segment = '' AND bucket >= %s GROUP BY test_id, variant_key UNION ALL SELECT t.id AS test_id, e.variant_key, COUNT(*) AS imps FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id <= t.tallied_id WHERE t.id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND e.event_type = 'impression' AND e.created_at >= %s AND e.created_at < %s GROUP BY t.id, e.variant_key UNION ALL SELECT t.id AS test_id, e.variant_key, COUNT(*) AS imps FROM {$wpdb->prefix}improveab_tests t JOIN {$wpdb->prefix}improveab_events e ON e.test_id = t.id AND e.id > t.tallied_id WHERE t.id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND e.event_type = 'impression' AND e.created_at >= %s GROUP BY t.id, e.variant_key UNION ALL SELECT test_id, variant_key, SUM(impressions) AS imps FROM {$wpdb->prefix}improveab_daily WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND metric = '' AND segment = '' AND day > %s GROUP BY test_id, variant_key", array_merge( $ids, array( $edge ), $ids, array( $since, $edge ), $ids, array( $since ), $ids, array( $day ) ) ), ARRAY_A ); } return '' === $wpdb->last_error ? (array) $rows : array(); } /** * What the traffic split check needs for every running and paused test: * its variant keys, and the visitors counted per variant (the tally, * the events past it and any rolled-up days; for a test whose split * check started over, those since then, as split_counts() counts * them). Three queries however many tests there are, and one more for * each moment tests' split checks restarted at. * * @return array|null Map of test_id => { test: { variants, since }, * counts: variant_key => visitors }, or null * when a query failed (so the caller does not cache * it). */ public static function live_split_counts() { global $wpdb; ImproveAB_Install::catch_up(); $tests = $wpdb->get_results( "SELECT id, meta FROM {$wpdb->prefix}improveab_tests WHERE status IN ('running','paused')", ARRAY_A ); if ( '' !== $wpdb->last_error ) { return null; } if ( empty( $tests ) ) { return array(); } $out = array(); foreach ( $tests as $t ) { $meta = ! empty( $t['meta'] ) ? json_decode( $t['meta'], true ) : array(); $out[ (int) $t['id'] ] = array( 'test' => array( 'variants' => array(), 'since' => self::split_since( array( 'meta' => $meta ) ), ), 'counts' => array(), ); } $ids = array_keys( $out ); $variants = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key FROM {$wpdb->prefix}improveab_variants WHERE test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") ORDER BY test_id ASC, sort_order ASC", $ids ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return null; } foreach ( (array) $variants as $v ) { $out[ (int) $v['test_id'] ]['test']['variants'][] = array( 'variant_key' => $v['variant_key'] ); } // The counts as compute_results() reads them, each test's in one // statement: one for every test whose split check never started // over, and one for each moment the others' checks restarted at // (after a move between the editions every test shares one). $whole = array(); $groups = array(); foreach ( $out as $tid => $row ) { if ( empty( $row['test']['since'] ) ) { $whole[] = $tid; continue; } $groups[ $row['test']['since'] ][] = $tid; } $rows = array(); if ( $whole ) { $got = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, variant_key, SUM(impressions) AS imps FROM {$wpdb->prefix}improveab_tally WHERE metric = '' AND segment = '' AND test_id IN (" . implode( ',', array_fill( 0, count( $whole ), '%d' ) ) . ") GROUP BY test_id, variant_key UNION ALL SELECT e.test_id, e.variant_key, COUNT(*) AS imps FROM {$wpdb->prefix}improveab_events e JOIN {$wpdb->prefix}improveab_tests t ON t.id = e.test_id AND e.id > t.tallied_id WHERE e.id > %d AND e.test_id IN (" . implode( ',', array_fill( 0, count( $whole ), '%d' ) ) . ") AND e.event_type = 'impression' GROUP BY e.test_id, e.variant_key UNION ALL SELECT test_id, variant_key, SUM(impressions) AS imps FROM {$wpdb->prefix}improveab_daily WHERE metric = '' AND segment = '' AND test_id IN (" . implode( ',', array_fill( 0, count( $whole ), '%d' ) ) . ") GROUP BY test_id, variant_key", array_merge( $whole, array( self::lowest_watermark( $whole ) ), $whole, $whole ) ), ARRAY_A ); if ( '' !== $wpdb->last_error ) { return null; } $rows = (array) $got; } foreach ( $groups as $since => $tids ) { $got = self::since_rows( $tids, (string) $since, '' ); if ( '' !== $wpdb->last_error ) { return null; } $rows = array_merge( $rows, $got ); } foreach ( (array) $rows as $r ) { $tid = (int) $r['test_id']; if ( ! isset( $out[ $tid ] ) ) { continue; } $key = (string) $r['variant_key']; $out[ $tid ]['counts'][ $key ] = ( isset( $out[ $tid ]['counts'][ $key ] ) ? $out[ $tid ]['counts'][ $key ] : 0 ) + (int) $r['imps']; } return $out; } /** * Lifetime impression / conversion totals per test id: the tally, the * events past it and the rolled-up days, in one statement however many * tests there are. * * @param int[]|null $ids Only these tests (the dashboard widget's few * rows), or null for every test (the list). * @return array Map of test_id => array( impressions, conversions ). */ public static function totals( $ids = null ) { ImproveAB_Install::catch_up(); if ( null === $ids ) { // Kept only when the read was slow, as remember() keeps every // aggregate: a quick one is simply run again, so the list shows // a first visitor at once. return self::remember( 'totals', static function () { return self::compute_totals( null ); }, static function () { return self::newest_event(); } ); } $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) ); return $ids ? self::compute_totals( $ids ) : array(); } /** * The lowest tally watermark of some tests (or of every test): a report * on many tests reads only the events above it, one range of the * primary key, and keeps those past each test's own watermark. A * watermark moves up, or down only to the newest event there is when * the newest were deleted (ImproveAB_Install::clamp_watermarks()), past * which there is nothing to read; so one read before the report's * statement is never above an event that statement should count. A new * test starts at the newest event (create()) and the job moves every * test with nothing left to sum to its horizon, so this stays near the * newest events. * * @param int[]|null $ids Test ids, or null for every test. * @return int */ private static function lowest_watermark( $ids = null ) { global $wpdb; $low = $ids ? $wpdb->get_var( $wpdb->prepare( "SELECT MIN(tallied_id) FROM {$wpdb->prefix}improveab_tests WHERE id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ) ) : $wpdb->get_var( "SELECT MIN(tallied_id) FROM {$wpdb->prefix}improveab_tests" ); return max( 0, (int) $low ); } /** * The uncached read behind totals(). * * @param int[]|null $ids Test ids, or null for all. * @return array */ private static function compute_totals( $ids ) { global $wpdb; $totals = array(); // One statement over every table, as compute_results() reads them, // so a tally window or a rollup between two reads cannot count // anything twice. Each event past its own test's watermark is added // (the watermark is read in the statement, by the join), and only // the events past the lowest watermark are looked at, one range of // the primary key (lowest_watermark()). if ( $ids ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, SUM(impressions) AS imps, SUM(conversions) AS convs FROM {$wpdb->prefix}improveab_tally WHERE metric = '' AND segment = '' AND test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") GROUP BY test_id UNION ALL SELECT e.test_id, SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS imps, SUM(CASE WHEN e.event_type = 'conversion' THEN 1 ELSE 0 END) AS convs FROM {$wpdb->prefix}improveab_events e JOIN {$wpdb->prefix}improveab_tests t ON t.id = e.test_id AND e.id > t.tallied_id WHERE e.id > %d AND e.test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") GROUP BY e.test_id UNION ALL SELECT test_id, SUM(impressions) AS imps, SUM(conversions) AS convs FROM {$wpdb->prefix}improveab_daily WHERE metric = '' AND segment = '' AND test_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") GROUP BY test_id", array_merge( $ids, array( self::lowest_watermark( $ids ) ), $ids, $ids ) ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT test_id, SUM(impressions) AS imps, SUM(conversions) AS convs FROM {$wpdb->prefix}improveab_tally WHERE metric = '' AND segment = '' GROUP BY test_id UNION ALL SELECT e.test_id, SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS imps, SUM(CASE WHEN e.event_type = 'conversion' THEN 1 ELSE 0 END) AS convs FROM {$wpdb->prefix}improveab_events e JOIN {$wpdb->prefix}improveab_tests t ON t.id = e.test_id AND e.id > t.tallied_id WHERE e.id > %d GROUP BY e.test_id UNION ALL SELECT test_id, SUM(impressions) AS imps, SUM(conversions) AS convs FROM {$wpdb->prefix}improveab_daily WHERE metric = '' AND segment = '' GROUP BY test_id", self::lowest_watermark( null ) ), ARRAY_A ); } $rows = '' === $wpdb->last_error ? (array) $rows : array(); foreach ( (array) $rows as $r ) { $tid = (int) $r['test_id']; if ( ! isset( $totals[ $tid ] ) ) { $totals[ $tid ] = array( 0, 0 ); } $totals[ $tid ][0] += (int) $r['imps']; $totals[ $tid ][1] += (int) $r['convs']; } return $totals; } /** * A request value as a string. json_decode() hands back arrays for * nested JSON, and a bare (string) cast of one is a PHP warning - or, * inside a core helper such as esc_url(), a TypeError on PHP 8 - so a * malformed body could take an admin route down. Anything that is not * a scalar reads as empty instead, and NUL bytes (which no sanitizer * in core strips) are dropped so they never reach the database, an * email subject, or a . * * @param mixed $value Raw request value. * @return string */ public static function as_string( $value ) { if ( ! is_scalar( $value ) ) { return ''; } return str_replace( "\0", '', (string) $value ); } /** * Words a person typed (a test's name, description and notes, a * version's label and text), as sanitize_text_field() or, with * $multiline, sanitize_textarea_field() leave them, stored as the * literal text they are (every reader escapes it on output), with every * percent sign, ampersand and lone "<" kept. * * Those sanitizers strip anything shaped like a %XX escape, so * "20%Deals" was stored as "20als", and HTML-encode everything after a * lone "<", so "Price < 10 & up" was stored as "Price < 10 & up" * and every screen, email and CSV showed the entities. Percent signs and * ampersands go through masked as control characters no typed text * keeps, so the only entities left afterwards are the ones the * sanitizer wrote, which are decoded; an entity someone typed * ("<b>") stays the text it was, however often it is saved. * * @param mixed $raw Raw value. * @param bool $multiline Keep line breaks. * @return string */ public static function sanitize_words( $raw, $multiline = false ) { $s = str_replace( array( "\x7f", "\x1f", '%', '&' ), array( '', '', "\x7f", "\x1f" ), self::as_string( $raw ) ); $s = $multiline ? sanitize_textarea_field( $s ) : sanitize_text_field( $s ); $s = wp_specialchars_decode( $s, ENT_QUOTES ); return str_replace( array( "\x7f", "\x1f" ), array( '%', '&' ), $s ); } /** * A page path, URL or part of one, typed or picked in the admin, kept * as the text a visitor's address decodes to. sanitize_text_field() * strips percent-encoded octets, which turned every non-Latin path * (/%D1%81%D0%BF…/) into "//": a thank-you page goal that never * converted and page patterns that matched nothing. Decoding first * keeps the characters (the runtime and page_in_scope() compare * decoded paths); a value that does not decode to UTF-8 keeps its * octets as typed. * * @param mixed $raw Raw value. * @param int $max Length clamp, in characters. * @return string */ public static function sanitize_path_text( $raw, $max ) { $s = self::as_string( $raw ); $d = rawurldecode( $s ); if ( $d !== $s && wp_check_invalid_utf8( $d ) === $d ) { $s = $d; } // Octets still present survive sanitize_text_field() only masked. $s = str_replace( "\x7f", '%', sanitize_text_field( str_replace( '%', "\x7f", $s ) ) ); return mb_substr( $s, 0, $max ); } /** * The post each address on this site names, by variant key: what a URL * test records when it is saved (URL_POSTS). An address on another * host, or one that names no post (an archive, a search), is left out. * * @param array $urls Addresses by variant key. * @return array Post ids by variant key. */ private static function url_posts( $urls ) { // Hosts in the form a browser names them (see redirect_entry()). $home = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $out = array(); foreach ( $urls as $key => $url ) { $host = (string) wp_parse_url( (string) $url, PHP_URL_HOST ); if ( '' === (string) $url || ( '' !== $host && improveab_ascii_host( $host ) !== $home ) ) { continue; } $post = url_to_postid( (string) $url ); if ( $post > 0 ) { $out[ (string) $key ] = (int) $post; } } return $out; } /** * The addresses of each running URL test that no longer name the page * they named when the test was saved: the post is gone or no longer * published, or its address is another one now (the permalink setting * changed, its slug or its parent did), which WordPress redirects the * old address to and the test's redirect never sees. Such a test splits * nobody; the list and its results page say "This address changed. * Edit the test." Only addresses that named a post when saved * (URL_POSTS) are judged. * * @param array $tests Tests with variants; their meta may be left out * (the list's summary view), then it is read. * @return array Variant keys by test id, only for tests with some. */ public static function moved( $tests ) { global $wpdb; $running = array(); foreach ( (array) $tests as $t ) { if ( is_array( $t ) && isset( $t['type'], $t['status'], $t['id'] ) && 'url' === $t['type'] && 'running' === $t['status'] ) { $running[ (int) $t['id'] ] = $t; } } if ( empty( $running ) ) { return array(); } $metas = array(); foreach ( $running as $id => $t ) { if ( array_key_exists( 'meta', $t ) ) { $metas[ $id ] = is_array( $t['meta'] ) ? $t['meta'] : array(); } } if ( count( $metas ) < count( $running ) ) { $found = $wpdb->get_results( "SELECT id, meta FROM {$wpdb->prefix}improveab_tests WHERE type = 'url' AND status = 'running'", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table name internal, the condition a literal; the list asks once. foreach ( (array) $found as $row ) { if ( ! isset( $metas[ (int) $row['id'] ] ) ) { $decoded = $row['meta'] ? json_decode( (string) $row['meta'], true ) : array(); $metas[ (int) $row['id'] ] = is_array( $decoded ) ? $decoded : array(); } } } $home = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $posts = array(); foreach ( $metas as $meta ) { if ( isset( $meta[ self::URL_POSTS ] ) && is_array( $meta[ self::URL_POSTS ] ) ) { $posts = array_merge( $posts, array_map( 'absint', array_values( $meta[ self::URL_POSTS ] ) ) ); } } if ( empty( $posts ) ) { return array(); } _prime_post_caches( array_unique( $posts ), false, false ); $out = array(); foreach ( $running as $id => $t ) { $named = isset( $metas[ $id ][ self::URL_POSTS ] ) && is_array( $metas[ $id ][ self::URL_POSTS ] ) ? $metas[ $id ][ self::URL_POSTS ] : array(); foreach ( isset( $t['variants'] ) ? (array) $t['variants'] : array() as $v ) { $key = (string) $v['variant_key']; $url = (string) $v['url']; if ( empty( $named[ $key ] ) || '' === $url ) { continue; } $host = (string) wp_parse_url( $url, PHP_URL_HOST ); if ( '' !== $host && improveab_ascii_host( $host ) !== $home ) { continue; } $post = absint( $named[ $key ] ); $link = 'publish' === get_post_status( $post ) ? get_permalink( $post ) : false; if ( ! $link || ! self::url_names( $url, (string) $link ) ) { $out[ $id ][] = $key; } } } return $out; } /** * The variant keys of one test whose address moved, as moved() finds them. * * @param array $test Test with variants and meta. * @return string[] */ public static function moved_addresses( $test ) { $all = self::moved( array( $test ) ); return isset( $test['id'], $all[ (int) $test['id'] ] ) ? $all[ (int) $test['id'] ] : array(); } /** * Whether a stored address names the page at `$link` (its permalink): * the same path, decoded and the trailing slash ignored, and every * query parameter of the permalink present with the same value * (plain permalinks name a page by its query; a campaign tag the * address adds does not matter), as the redirect script decides. * * @param string $url Stored address. * @param string $link The page's permalink. * @return bool */ private static function url_names( $url, $link ) { $path = static function ( $u ) { $p = rtrim( rawurldecode( (string) wp_parse_url( $u, PHP_URL_PATH ) ), '/' ); return '' === $p ? '/' : $p; }; if ( $path( $url ) !== $path( $link ) ) { return false; } $have = array(); $want = array(); wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $have ); wp_parse_str( (string) wp_parse_url( $link, PHP_URL_QUERY ), $want ); foreach ( $want as $k => $v ) { if ( ! isset( $have[ $k ] ) || ! is_scalar( $have[ $k ] ) || (string) $have[ $k ] !== (string) $v ) { return false; } } return true; } /** * Whether a page-visit goal's value, matched as "Path contains", would * match every page of the site: no query, and a path the home path * holds ("/", or "/shop/" and a piece of it on a site installed in * /shop/), which every address on the site contains. A query names one * page (plain permalinks, ?page_id=12), so a value with one never does. * New test and the panel refuse such a goal; sanitize_goal() stores it * as "Path is exactly". admin.js and picker.js matchesEveryPage() apply * the same rule. * * @param string $value Goal value, a path or a whole address. * @return bool */ public static function goal_matches_every_page( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return false; } if ( preg_match( '#^(https?:)?//#i', $value ) ) { $path = (string) wp_parse_url( $value, PHP_URL_PATH ); $query = (string) wp_parse_url( $value, PHP_URL_QUERY ); $path = '' === $path ? '/' : $path; } else { $value = (string) preg_replace( '/#.*$/s', '', $value ); $at = strpos( $value, '?' ); $path = false === $at ? $value : substr( $value, 0, $at ); $query = false === $at ? '' : substr( $value, $at + 1 ); } if ( '' !== trim( $query, '&' ) ) { return false; } $home = rawurldecode( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ); $home = '/' . trim( $home, '/' ) . '/'; $home = '//' === $home ? '/' : $home; return '' === $path || false !== strpos( $home, rawurldecode( $path ) ); } /** * Generate sequential variant keys: default, v1, v2, ... * * @param int $count Number of variants. * @return array */ public static function variant_keys( $count ) { $keys = array( 'default' ); for ( $i = 1; $i < $count; $i++ ) { $keys[] = 'v' . $i; } return $keys; } /** * The letter of a variant's place in its test: A for the original, then * B to F. Every screen names a variant with its letter beside it. * * @param int $index Position in the test, 0 for the original. * @return string */ public static function variant_letter( $index ) { $index = max( 0, (int) $index ); return $index < 26 ? chr( 65 + $index ) : (string) ( $index + 1 ); } /** * The name a variant gets when nobody names it: "Original", then * "Variant B" to "Variant F". * * @param int $index Position in the test, 0 for the original. * @return string */ public static function default_label( $index ) { $index = max( 0, (int) $index ); if ( 0 === $index ) { return __( 'Original', 'improveab-ab-testing' ); } /* translators: %s: the variant's letter, B to F. Used as the variant's name. */ return sprintf( __( 'Variant %s', 'improveab-ab-testing' ), self::variant_letter( $index ) ); } /** * A variant's name as the widget, the menus, emails and summaries show * it. A stored name that is exactly the one the plugin used to give that * position on its own ("Control" for the original; "Challenger" for the * first variant, "Variant 1" to "Variant 5" and the bare keys "V1" to * "V5" each at its own position) reads as today's default for the * variant's letter, in the site's language, and so does today's default * stored in English. Anything a person typed is shown as typed, so * "Control - the January headline" stays, and so does a "V2" typed for * the first variant: the admin's variantLabel() keeps the same rule, so * a name reads alike in the app, the menus and the emails. Display only: * stored names are never rewritten, and the class suffixes (-default, * -v1 to -v5) keep their old numbering because they are already pasted * into builders. * * @param string $label Stored label. * @param int $index Position in the test, 0 for the original. * @return string */ public static function display_label( $label, $index ) { $label = (string) $label; $index = max( 0, (int) $index ); if ( 0 === $index ) { $given = array( '', 'Control', 'Original' ); } else { $given = array( '', 'Variant ' . self::variant_letter( $index ), 'Variant ' . $index, 'V' . $index, 'v' . $index ); if ( 1 === $index ) { $given[] = 'Challenger'; } } return in_array( $label, $given, true ) ? self::default_label( $index ) : $label; } /** * A variant's shown name found by its key, with its letter: the letter * and name of the variant at that key's place in the test. * * @param array $test Hydrated test (with variants). * @param string $key Variant key. * @return array|null array( letter, name ), or null for an unknown key. */ public static function variant_by_key( $test, $key ) { $variants = isset( $test['variants'] ) && is_array( $test['variants'] ) ? array_values( $test['variants'] ) : array(); foreach ( $variants as $i => $v ) { if ( isset( $v['variant_key'] ) && (string) $v['variant_key'] === (string) $key ) { return array( self::variant_letter( $i ), self::display_label( isset( $v['label'] ) ? $v['label'] : '', $i ) ); } } return null; } /** * Clamp a base name so that a suffix of up to $reserve characters still * fits the 191-character name column. create() clamps and then checks * for duplicates, so a suffix that gets cut off would make every derived * name collide with its source. * * @param string $name Source name. * @param int $reserve Characters to leave free for the suffix. * @return string */ private static function fit_name( $name, $reserve ) { return mb_substr( (string) $name, 0, max( 20, 191 - (int) $reserve ) ); } /** * Whether a test with the given name already exists. * * @param string $name Test name. * @param int $exclude_id Test ID to exclude (for updates). * @return bool */ private static function name_exists( $name, $exclude_id = 0 ) { global $wpdb; $id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}improveab_tests WHERE name = %s AND id <> %d", $name, absint( $exclude_id ) ) ); return (bool) $id; } /** * Whether a test already has this slug. * * @param string $slug Slug. * @return bool */ private static function slug_exists( $slug ) { global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}improveab_tests WHERE slug = %s", $slug ) ); } /** * The slug a create asks for, when it is one: lower-case Latin letters, * digits and "_" in words joined by single hyphens, at most 170 * characters (the clamp below), and read back unchanged by * sanitize_title(), which /event puts every slug through. '' otherwise, * and the test takes its name's slug. * * @param mixed $raw Raw value. * @return string */ private static function requested_slug( $raw ) { $slug = self::as_string( $raw ); if ( '' === $slug || strlen( $slug ) > 170 || ! preg_match( '/^[a-z0-9_]+(?:-[a-z0-9_]+)*$/', $slug ) || sanitize_title( $slug ) !== $slug ) { return ''; } return $slug; } /** * The slug a name gives: sanitize_title() of the name read as the * literal text it is (sanitize_words()), Latin letters, digits, "_" * and "-" only, 'test' when nothing is left. The New test form's class * preview follows the same rule (admin.js slugify()) and sends what it * shows (requested_slug()). * * @param string $name Test name. * @return string */ private static function name_slug( $name ) { // A "<" and a "%" in a name are the characters they are ("We <3 // coffee", "20%Deals"), punctuation a slug leaves out. sanitize_title() // reads the first as the start of a tag, whose strip_tags() took the // rest of the name ("we"), and keeps the second with two hex digits // after it as an escaped octet, which the line below then dropped // with them ("20als"). $base = sanitize_title( str_replace( array( '<', '%' ), '', $name ) ); // sanitize_title() percent-encodes anything non-Latin. Those octets // make an unusable CSS class and, when clamped below, can split // mid-octet so the /event endpoint (which re-sanitizes the slug) // never matches. Keep the ASCII part only; 'test' when nothing is left. $base = trim( preg_replace( '/-+/', '-', preg_replace( '/%[0-9a-fA-F]{2}/', '', $base ) ), '-' ); // Leave room within the 191-char columns for BOTH the "-NN" // uniqueness suffix and the longest derived variant class, // "improveab-{slug}-default" — wpdb refuses (not truncates) an // oversized value, which made every long-named test fail with a // generic DB error until the slug was clamped this far. // Trimmed again after the clamp: a cut that ends on a dash left a // slug /event (which re-sanitizes it) never matched. $base = rtrim( substr( $base, 0, 170 ), '-' ); return '' === $base ? 'test' : $base; } /** * The slug a new test is saved with: the one the request asked for, * else its name's (name_slug()), made unique with "-2", "-3" and on. * * @param string $name Test name. * @param string $want The slug the request asked for (requested_slug()), or ''. * @return string */ private static function unique_slug( $name, $want = '' ) { $base = '' !== $want ? $want : self::name_slug( $name ); $slug = $base; $i = 2; // The slug is the visitor's storage key "improveab:{slug}", which shares // its namespace with the runtime's own keys (improveab:vid, improveab:seen, // the improveab:ho: prefix, improveab:vt): a test called "vid" would // overwrite every visitor's id. Those names start at "-2". if ( in_array( $base, self::RESERVED_SLUGS, true ) ) { $slug = $base . '-' . $i; $i++; } while ( self::slug_exists( $slug ) ) { $slug = $base . '-' . $i; $i++; } return $slug; } /** * Meta input without the plugin's own records, which only the plugin * writes: when the split check started over (SPLIT_SINCE) and the posts * a URL test's addresses named (URL_POSTS). A caller's write never * sets, moves or clears one, and none counts toward a caller's bounds. * Each is named, never the `improveab_` prefix as a whole: the route has * always taken every other key as the caller's (CONTRACTS 2.1). * * @param mixed $meta Raw meta input. * @return mixed */ private static function without_own_meta( $meta ) { if ( is_array( $meta ) ) { unset( $meta[ self::SPLIT_SINCE ], $meta[ self::URL_POSTS ] ); } return $meta; } /** * Sanitize the free-form per-test meta object. * * The column exists so that a later feature can keep structured per-test * state without a schema release: a new column for every such feature * would mean a database change each time. * * It is opaque server-side storage. The plugin never prints a meta value * and never localizes one to the front end (cached_set() does not even * load the column), so this sanitizer's job is to guarantee that what * comes back out is the shape and the bytes that went in, bounded in size * and free of NUL bytes - not to make a value safe to echo; anything that * ever renders one escapes it there. A text sanitizer here would corrupt * exactly the values the column is for: _sanitize_text_fields() strips * every %xx octet, so a URL with a query string would come back wrong. * * Keys are merged one level deep so two owners can keep state on the same * test without overwriting each other. Namespace your keys ("acme_notes", * "acme_funnel"); an explicit null removes one. A write that breaks the * bounds or the shape is refused as a WP_Error instead of being quietly * truncated: a caller must never read back a half-stored object it * believes it saved. * * @param mixed $raw Raw value (a decoded JSON object). * @param array $existing Currently stored meta, for a partial merge. * @return array|WP_Error */ private static function sanitize_meta( $raw, $existing = array() ) { $out = is_array( $existing ) ? $existing : array(); if ( ! is_array( $raw ) ) { return self::meta_error( 'shape' ); } // Bounded before the work rather than after it: the admin routes have // no body-size gate of their own (only /event does), so an oversized // object is refused before every key of it is walked and copied. $incoming = wp_json_encode( $raw ); if ( ! is_string( $incoming ) ) { // Unencodable input (invalid UTF-8, an object, nesting past what // json_encode itself allows) could never round-trip. return self::meta_error( 'shape' ); } if ( strlen( $incoming ) > self::META_MAX_BYTES ) { return self::meta_error( 'size' ); } foreach ( $raw as $key => $value ) { // Validated, never rewritten: sanitize_key() would silently fold // "Acme_Funnel" and "acme.funnel" onto a neighbour's key, and an // all-numeric key would turn the whole object into a JSON array. if ( ! is_string( $key ) || ! preg_match( '/^[a-z][a-z0-9_-]{0,39}$/', $key ) ) { return self::meta_error( 'shape' ); } if ( null === $value ) { unset( $out[ $key ] ); continue; } $clean = self::sanitize_meta_value( $value, 1 ); if ( is_wp_error( $clean ) ) { return $clean; } $out[ $key ] = $clean; } // Measured on the merged object, not on the incoming half: the write // is refused when the test as a whole would no longer fit, so one // owner can never silently push another's state out. The plugin's // own records (without_own_meta()'s keys, small and bounded) are // not a caller's and count toward neither bound: a caller who filled // its 50 keys keeps updating them on a URL test, or after its split // check started over. $callers = self::without_own_meta( $out ); $encoded = wp_json_encode( $callers ); if ( count( $callers ) > self::META_MAX_KEYS || ! is_string( $encoded ) || strlen( $encoded ) > self::META_MAX_BYTES ) { return self::meta_error( 'size' ); } return $out; } /** * One meta value: scalars as themselves, arrays recursively to a bounded * depth with their keys and their nulls intact, so a JSON list round-trips * as a JSON list. Anything else (an object, a resource) is refused rather * than substituted - a silent type change is what a storage API can least * afford. * * @param mixed $value Raw value. * @param int $depth Current depth, counting from 1. * @return mixed|WP_Error */ private static function sanitize_meta_value( $value, $depth ) { if ( null === $value || is_bool( $value ) || is_int( $value ) ) { return $value; } if ( is_float( $value ) ) { // NAN and INF encode as null and would read back as another type. return is_finite( $value ) ? $value : 0; } if ( is_string( $value ) ) { // Verbatim apart from the NUL strip in as_string(), an invalid // UTF-8 check (the column cannot hold it anyway) and the clamp. return mb_substr( wp_check_invalid_utf8( self::as_string( $value ) ), 0, self::META_MAX_STRING ); } if ( ! is_array( $value ) || $depth > self::META_MAX_DEPTH ) { return self::meta_error( 'shape' ); } $out = array(); foreach ( $value as $k => $v ) { // Nested keys are the feature's own data, so they are kept as // given (NUL stripped); only the top level carries the namespace // rule. Nothing is dropped and nested nulls are kept, so // [1, null, 3] comes back as [1, null, 3] and not as a gapped // object. $k = is_int( $k ) ? $k : self::as_string( $k ); $clean = self::sanitize_meta_value( $v, $depth + 1 ); if ( is_wp_error( $clean ) ) { return $clean; } $out[ $k ] = $clean; } return $out; } /** * A refusal from the meta sanitizer, as one of two codes: the caller is * code, so the code matters more than the sentence. * * @param string $reason 'size' or 'shape'. * @return WP_Error */ private static function meta_error( $reason ) { if ( 'size' === $reason ) { return new WP_Error( 'improveab_meta_too_big', __( 'That is more extra configuration than one test can store.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } return new WP_Error( 'improveab_meta_shape', __( 'That extra configuration is not a shape a test can store. Use namespaced keys, and scalar or list values.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } /** * Sanitize a CSS selector stored for a visual (point-and-click) test. * * Selectors are written by administrators only, but they end up inside a * front-end style block and the runtime config, so the characters that * could break out of those contexts are stripped (the runtime and the * hide rule both take a single selector, so commas are dropped too) and * the length is clamped. * * @param mixed $raw Raw selector. * @return string */ public static function sanitize_selector( $raw ) { $sel = sanitize_text_field( self::as_string( $raw ) ); $sel = str_replace( array( '<', '{', '}', ',', ';' ), '', $sel ); // Until none is left: one pass over "//**" leaves "/*", which in // the head's shared hide rule comments out every rule after it. do { $was = $sel; $sel = str_replace( array( '/*', '*/' ), '', $sel ); } while ( $sel !== $was ); $sel = trim( mb_substr( $sel, 0, 400 ) ); // An open string, bracket or parenthesis would run on into the next // rule of the head's hide block and swallow it; such a selector // matches nothing anyway, so it is refused whole. return self::css_balanced( $sel ) ? $sel : ''; } /** * Whether a piece of CSS closes every string, bracket and parenthesis * it opens and does not end on an escape. * * @param string $css CSS text. * @return bool */ private static function css_balanced( $css ) { $stack = array(); $quote = ''; $len = strlen( $css ); for ( $i = 0; $i < $len; $i++ ) { $c = $css[ $i ]; if ( '\\' === $c ) { if ( $i + 1 >= $len ) { return false; } $i++; continue; } if ( '' !== $quote ) { if ( $c === $quote ) { $quote = ''; } continue; } if ( '"' === $c || "'" === $c ) { $quote = $c; } elseif ( '(' === $c || '[' === $c ) { $stack[] = '(' === $c ? ')' : ']'; } elseif ( ')' === $c || ']' === $c ) { if ( array_pop( $stack ) !== $c ) { return false; } } } return '' === $quote && empty( $stack ); } /** * What a whole-element variant may contain: everything post content may * (wp_kses_post), plus what page builders put in the sections people * pick and post content never needs. Inline SVG icons (shapes only: no * links, no scripts, no animation, no external references) and responsive * images (srcset/sizes, picture/source). Without them a variant lost its * icons and loaded full-size images on phones, which the panel's preview * never showed. A <noscript> is not allowed and is removed with its * contents before this list runs (clean_variant_html()): the runtime * applies a variant where scripts run, so the fallback a lazy loader * wraps around an image is a second copy for nobody, and WordPress's * own kses reads one differently from release to release. * * @return array wp_kses allowlist. */ public static function variant_html_allowlist() { $tags = wp_kses_allowed_html( 'post' ); $paint = array( 'class' => true, 'style' => true, 'fill' => true, 'fill-rule' => true, 'fill-opacity' => true, 'clip-rule' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'stroke-opacity' => true, 'opacity' => true, 'transform' => true, ); $tags['svg'] = array_merge( $paint, array( 'xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'preserveaspectratio' => true, 'role' => true, 'aria-hidden' => true, 'aria-label' => true, 'focusable' => true ) ); $tags['g'] = $paint; $tags['path'] = array_merge( $paint, array( 'd' => true ) ); $tags['circle'] = array_merge( $paint, array( 'cx' => true, 'cy' => true, 'r' => true ) ); $tags['ellipse'] = array_merge( $paint, array( 'cx' => true, 'cy' => true, 'rx' => true, 'ry' => true ) ); $tags['rect'] = array_merge( $paint, array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true ) ); $tags['line'] = array_merge( $paint, array( 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true ) ); $tags['polyline'] = array_merge( $paint, array( 'points' => true ) ); $tags['polygon'] = array_merge( $paint, array( 'points' => true ) ); $tags['title'] = array(); $tags['picture'] = array( 'class' => true ); $tags['source'] = array( 'srcset' => true, 'sizes' => true, 'media' => true, 'type' => true, 'width' => true, 'height' => true ); $tags['img'] = array_merge( isset( $tags['img'] ) ? $tags['img'] : array(), array( 'srcset' => true, 'sizes' => true, 'decoding' => true, 'loading' => true, 'fetchpriority' => true ) ); return $tags; } /** * Markup without its <noscript> elements, contents and all (an unclosed * one runs to the end, as a browser reads it). The runtime applies a * variant where scripts run, where a noscript never shows; and core's * kses is not consistent about it: up to WordPress 7.1 it unwraps an * allowed one (the fallback image becomes visible, a second copy), from * 7.2 it stops reading at the first one whatever the allowlist says and * keeps nothing after it. Taking them out first gives the same markup on * every WordPress. * * @param string $html Markup. * @return string */ public static function without_noscript( $html ) { return (string) preg_replace( '#<noscript\b[^>]*>.*?(</noscript\s*>|$)#is', '', (string) $html ); } /** * Most characters a variant's markup holds, as stored and as shipped. */ const HTML_MAX = 20000; /** * A whole-element variant's markup as it is stored and as it ships to a * page: a <script>, <style> or <noscript> of the picked block removed * with its contents, the allowlist, then at most HTML_MAX characters. * Applying it to its own output changes nothing, so the visitor store * can run it again where it prints the markup * (ImproveAB_Render::config_entry()): a row that reached the table * another way than a save (a restore, an earlier release with * a looser filter) never ships unfiltered, and a save of the markup a * test holds stores it as it is. * * @param mixed $html Markup. * @return string '' when nothing is left. */ public static function clean_variant_html( $html ) { // Bounded before anything reads it, per character rather than byte // so no multibyte character is split: five times what is kept, the // most filtering ever adds (a bare & becomes &), so markup sent // at the bound is read whole. $html = mb_substr( self::as_string( $html ), 0, 5 * self::HTML_MAX ); // A picked block's own <style> or <script> goes with what is inside // it: kses drops only the tags, which left the CSS or code on the // page as text. An unclosed one runs to the end, as a browser reads // it. $html = self::filtered_html( (string) preg_replace( '#<(script|style)\b[^>]*>.*?(</\1\s*>|$)#is', '', $html ) ); // The bound is on what is kept, after filtering: filtering lengthens // typed markup (an unquoted attribute gets its quotes, a bare & is // spelled &), so 19,995 characters sent were stored as 22,661, // and a second pass that cut first lost the tail. A cut is made // where kses's own output allows one (html_prefix()) and filtered // again, which leaves it as it is. for ( $pass = 0; $pass < 3 && mb_strlen( $html ) > self::HTML_MAX; $pass++ ) { $html = self::filtered_html( self::html_prefix( $html, self::HTML_MAX ) ); } return mb_strlen( $html ) > self::HTML_MAX ? '' : $html; } /** * Markup through the variant allowlist and the srcset check until it * stops changing: kses is not quite a fixed point (an apostrophe in a * style attribute is written ' the first time and ' the * second), so what a save stores is what a second look leaves as it is. * * @param string $html Markup. * @return string */ private static function filtered_html( $html ) { $allow = self::variant_html_allowlist(); for ( $pass = 0; $pass < 3; $pass++ ) { $was = $html; $html = trim( self::clean_srcset( wp_kses( self::without_noscript( $html ), $allow ) ) ); if ( $was === $html ) { break; } } return $html; } /** * The longest start of filtered markup, at most $max characters, that * ends between two tags or between two characters of text: never inside * a tag, a comment or an entity. In kses's output every "<" opens a tag * or a comment and every ">" closes one (a ">" of text or of a comment * is written >), and every "&" begins an entity that a ";" ends. An * element the cut leaves open is closed by the browser, as innerHTML * closes it. * * The four characters looked for are ASCII, so their byte offsets in * the UTF-8 text fall between two characters: the byte functions cut * where the multibyte ones would, and need no mbstring, which a site * may lack (WordPress stands in for mb_substr() and mb_strlen() only). * * @param string $html Filtered markup. * @param int $max Characters. * @return string */ private static function html_prefix( $html, $max ) { $cut = mb_substr( $html, 0, $max ); $lt = strrpos( $cut, '<' ); $gt = strrpos( $cut, '>' ); if ( false !== $lt && ( false === $gt || $lt > $gt ) ) { $cut = substr( $cut, 0, $lt ); } $amp = strrpos( $cut, '&' ); $semi = strrpos( $cut, ';' ); if ( false !== $amp && ( false === $semi || $amp > $semi ) ) { $cut = substr( $cut, 0, $amp ); } return $cut; } /** * kses checks the protocol of src and href, but not of srcset, which * variant_html_allowlist() adds for responsive images (core's post rules * have no srcset). Every candidate's address must pass the same check * src gets and carry at most one width or density descriptor ("800w", * "2x"), or it is dropped, and the attribute with it when none is left. * kses has already written each attribute as name="value" or * name='value'. * * @param string $html Variant HTML after wp_kses(). * @return string */ private static function clean_srcset( $html ) { return (string) preg_replace_callback( '/(\ssrcset=)(["\'])(.*?)\2/is', function ( $m ) { $keep = array(); $protocols = wp_allowed_protocols(); foreach ( self::srcset_candidates( $m[3] ) as $candidate ) { list( $url, $descriptor ) = $candidate; if ( '' === $url || wp_kses_bad_protocol( $url, $protocols ) !== $url ) { continue; } // What follows the address is one descriptor or nothing; a // browser drops anything else, so it is not kept either. if ( '' !== $descriptor && ! preg_match( '/^\d+(\.\d+)?[wx]$/i', $descriptor ) ) { continue; } $keep[] = trim( $url . ' ' . $descriptor ); } return empty( $keep ) ? '' : $m[1] . $m[2] . implode( ', ', $keep ) . $m[2]; }, (string) $html ); } /** * Split a srcset into [ address, descriptor ] pairs the way a browser * does: an address runs to whitespace, so a comma inside it (CDN * transforms such as "w_400,h_300") is part of it, and only a comma * after the descriptor, outside parentheses, starts the next candidate. * * @param string $srcset Attribute value. * @return array[] */ private static function srcset_candidates( $srcset ) { $out = array(); $len = strlen( $srcset ); $i = 0; $space = " \t\n\r\f"; while ( $i < $len ) { // Whitespace and commas between candidates. while ( $i < $len && ( false !== strpos( $space, $srcset[ $i ] ) || ',' === $srcset[ $i ] ) ) { $i++; } if ( $i >= $len ) { break; } // The address runs to the next whitespace; commas inside it // belong to it (a CDN's "w_400,h_300"), trailing ones end it. $start = $i; while ( $i < $len && false === strpos( $space, $srcset[ $i ] ) ) { $i++; } $url = substr( $srcset, $start, $i - $start ); $descriptor = ''; if ( ',' === substr( $url, -1 ) ) { $url = rtrim( $url, ',' ); } else { // Then the descriptor, up to a comma outside parentheses. $start = $i; $depth = 0; while ( $i < $len ) { $c = $srcset[ $i ]; if ( '(' === $c ) { $depth++; } elseif ( ')' === $c && $depth > 0 ) { $depth--; } elseif ( ',' === $c && 0 === $depth ) { break; } $i++; } $descriptor = trim( substr( $srcset, $start, $i - $start ) ); $i++; } $out[] = array( $url, $descriptor ); } return $out; } /** * Whether a version's (sanitized) edits change the element it is applied * to, as the runtime would apply them: new words (an empty string * clears the element, and is applied as such) or new markup. A version * that changes nothing is a page identical to the original, counted * under its own name. * * @param array $edits Sanitized edits. * @return bool */ private static function changes_element( $edits ) { if ( isset( $edits['text'] ) || ( isset( $edits['mode'], $edits['html'] ) && '' !== $edits['html'] ) ) { return true; } return false; } /** * The error for a version of a point-and-click test that changes nothing. * * @return WP_Error */ private static function no_change_error() { return new WP_Error( 'improveab_edits', __( 'Every version of a point-and-click test except the original has to change the element: give it new words or new markup.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } /** * The error for a Duplicate whose copy would have a version that * changes nothing (no new words or markup). * * @return WP_Error */ private static function copy_no_change_error() { return new WP_Error( 'improveab_edits', __( 'This test cannot be copied: one of its versions has no new words or markup, so the copy would show visitors the original twice.', 'improveab-ab-testing' ), array( 'status' => 400 ) ); } /** * Sanitize the edits payload of a visual test variant. * * Shape: { selector, text, page, mode, html }. `selector` targets the * element; `text` is the plain replacement the runtime applies via * textContent. When `mode` is `html` (or absent, with no `text`), `html` * holds an inline-formatted replacement (filtered by * clean_variant_html()) that the runtime applies via innerHTML instead, * so styled fragments such as colored spans survive. `page` is the path * the test was created on, used only to build preview links in the * admin. * * @param mixed $raw Raw edits. * @return array */ private static function sanitize_edits( $raw ) { $raw = is_array( $raw ) ? $raw : array(); $out = array( 'selector' => isset( $raw['selector'] ) ? self::sanitize_selector( $raw['selector'] ) : '', ); if ( isset( $raw['text'] ) ) { // Plain text (the runtime applies it through textContent): // sanitize_words() keeps a stray "<" and every ampersand as typed. $out['text'] = mb_substr( self::sanitize_words( $raw['text'], true ), 0, 600 ); } // Markup is applied when the request says mode "html", or says nothing // about a mode and sends no words of its own: markup without a mode // used to be dropped whole, and a version stored with no change at all // was started like any other, splitting visitors between two pages // that read the same. $mode = isset( $raw['mode'] ) ? $raw['mode'] : null; if ( ( 'html' === $mode || ( ( null === $mode || '' === $mode ) && ! isset( $raw['text'] ) ) ) && isset( $raw['html'] ) ) { // Post-content rules and then some (variant_html_allowlist()), so // whole sections (divs, headings, images, icons, lists) survive, // not just inline spans; scripts and event handlers are still // stripped. Generous clamp: sections are big. $html = self::clean_variant_html( $raw['html'] ); if ( '' !== $html ) { $out['mode'] = 'html'; $out['html'] = $html; } } if ( isset( $raw['page'] ) ) { // The admin builds preview links from this value, so only a // same-site path may survive: no scheme, no protocol-relative // host, no fragment, and of the query only what names the page // under plain permalinks (?page_id=12, ?p=7), digits only. With // the path alone those previews opened the front page. $page = str_replace( array( '<', '>' ), '', self::sanitize_path_text( $raw['page'], 1000 ) ); $path = (string) wp_parse_url( $page, PHP_URL_PATH ); if ( '' !== $path && '/' === substr( $path, 0, 1 ) && '//' !== substr( $path, 0, 2 ) ) { $args = array(); parse_str( (string) wp_parse_url( $page, PHP_URL_QUERY ), $args ); $query = array(); foreach ( array( 'page_id', 'p' ) as $name ) { if ( isset( $args[ $name ] ) && is_string( $args[ $name ] ) && preg_match( '/^[1-9][0-9]{0,19}$/', $args[ $name ] ) ) { $query[] = $name . '=' . $args[ $name ]; } } $query = $query ? '?' . implode( '&', $query ) : ''; $out['page'] = mb_substr( $path, 0, 255 - strlen( $query ) ) . $query; } } return $out; } /** * Validate and sanitize a test's page scope (where it runs). * * @param mixed $raw Raw page scope: { mode, patterns }. * @return array */ private static function sanitize_pages( $raw ) { // Optional page scoping. Tests run on every page where their element // exists (mode "all": a header CTA or nav link is tested site-wide); // "include"/"exclude" restrict that to the pages the patterns name // (sanitize_page_pattern()), evaluated in the browser and on the // server alike. $pages = array( 'mode' => 'all', 'patterns' => array() ); if ( is_array( $raw ) ) { $pmode = isset( $raw['mode'] ) ? sanitize_text_field( $raw['mode'] ) : 'all'; if ( ! in_array( $pmode, array( 'all', 'include', 'exclude' ), true ) ) { $pmode = 'all'; } $patterns = array(); $raw_list = isset( $raw['patterns'] ) ? $raw['patterns'] : array(); if ( is_string( $raw_list ) ) { $raw_list = preg_split( '/[\r\n,]+/', $raw_list ); } foreach ( (array) $raw_list as $p ) { $p = self::sanitize_page_pattern( $p ); if ( '' === $p ) { continue; } $patterns[] = $p; if ( count( $patterns ) >= 50 ) { break; } } $patterns = array_values( array_unique( $patterns ) ); if ( empty( $patterns ) ) { $pmode = 'all'; } $pages = array( 'mode' => $pmode, 'patterns' => $patterns ); } return $pages; } /** * One page rule's pattern (CONTRACTS targeting.pages): a path, "*" * matching anything, and an optional query part after the first "?", * so a rule can name a page that plain permalinks tell apart by query * alone (/?page_id=12), where every page's path is the home path. A * whole address keeps its path and query; a fragment is dropped. The * path is kept as the text a visitor's address decodes to * (sanitize_path_text()), with a "?" or "#" it holds written %3F / %23 * so it is not read as the start of a query; the query's parameters * are kept in the order typed, each name and value decoded the same * way, with the characters that would change how the query reads * (%, &, #, and = in a name) and white space written as escapes. * Then fit_page_pattern(). * * @param mixed $raw A pattern as typed or picked. * @return string The pattern, or '' when nothing is left. */ public static function sanitize_page_pattern( $raw ) { $s = trim( self::as_string( $raw ) ); if ( '' === $s ) { return ''; } $query = null; if ( preg_match( '#^https?://#i', $s ) ) { $path = (string) wp_parse_url( $s, PHP_URL_PATH ); $q = wp_parse_url( $s, PHP_URL_QUERY ); if ( is_string( $q ) ) { $query = $q; } } else { $s = (string) preg_replace( '/#.*$/s', '', $s ); $at = strpos( $s, '?' ); $path = false === $at ? $s : substr( $s, 0, $at ); if ( false !== $at ) { $query = (string) substr( $s, $at + 1 ); } } $clean = static function ( $text ) { return trim( str_replace( array( '<', '>', '"', "'" ), '', self::sanitize_path_text( $text, 1000 ) ) ); }; $path = str_replace( array( '?', '#' ), array( '%3F', '%23' ), $clean( $path ) ); if ( '' === $path && null === $query ) { return ''; } if ( '' === $path || ( '/' !== $path[0] && '*' !== $path[0] ) ) { $path = '/' . $path; } if ( null !== $query ) { $pairs = array(); foreach ( explode( '&', $query ) as $pair ) { $eq = strpos( $pair, '=' ); $name = $clean( false === $eq ? $pair : substr( $pair, 0, $eq ) ); if ( '' === $name ) { continue; } $value = false === $eq ? '' : $clean( (string) substr( $pair, $eq + 1 ) ); $pairs[] = self::query_text( $name, true ) . '=' . self::query_text( $value, false ); } $query = implode( '&', $pairs ); } return self::fit_page_pattern( $path, $query ); } /** * A query name or value as a page pattern stores it: readable, with the * characters that would change how the query is read escaped. * * @param string $text Decoded text. * @param bool $is_name Whether it is a parameter's name ("=" escaped). * @return string */ private static function query_text( $text, $is_name ) { $from = array( '%', '&', '#', ' ' ); $to = array( '%25', '%26', '%23', '%20' ); if ( $is_name ) { $from[] = '='; $to[] = '%3D'; } return str_replace( $from, $to, $text ); } /** * A pattern within the 191 characters a page rule keeps. A rule matches * the whole path, so a longer one cut short matched nothing and its * test ran nowhere: the path is cut instead and ends in "*", which * reads the rest as anything, and a query part is kept whole (a cut * value never matches). Cut on a whole character, and never inside an * escape. When the query alone leaves no room, the pattern is cut as * it is. picker.js fitPattern() applies the same rule to the panel's * default. * * @param string $path Path part. * @param string|null $query Query part, without its "?"; null for none. * @return string */ public static function fit_page_pattern( $path, $query ) { $tail = null === $query ? '' : '?' . $query; $max = 191; if ( mb_strlen( $path . $tail ) <= $max ) { return $path . $tail; } $room = $max - 1 - mb_strlen( $tail ); if ( $room < 2 ) { return mb_substr( $path . $tail, 0, $max ); } $cut = (string) preg_replace( '/%[0-9A-Fa-f]?$/', '', mb_substr( $path, 0, $room ) ); return rtrim( $cut, '*' ) . '*' . $tail; } /** * Whether a test's page rule names a page by its query in some pattern * (a "?"): an earlier runtime would read that as part of the path, so * the runtime config entry asks for level 2 (CONTRACTS 10.1). * * @param mixed $pages Stored page scope. * @return bool */ public static function pages_have_query( $pages ) { if ( ! is_array( $pages ) || empty( $pages['mode'] ) || 'all' === $pages['mode'] || empty( $pages['patterns'] ) || ! is_array( $pages['patterns'] ) ) { return false; } foreach ( $pages['patterns'] as $p ) { if ( is_string( $p ) && false !== strpos( $p, '?' ) ) { return true; } } return false; } /** * Validate and sanitize goal configuration by type. * * @param string $type Goal type. * @param array $config Raw config. * @return array */ private static function sanitize_goal( $type, $config ) { switch ( $type ) { case 'url': $match = isset( $config['match'] ) && 'exact' === $config['match'] ? 'exact' : 'contains'; $value = isset( $config['value'] ) ? self::sanitize_path_text( $config['value'], 255 ) : ''; // "Path contains /" counts every visitor where they land: the // home page is what such a goal can mean. if ( 'contains' === $match && self::goal_matches_every_page( $value ) ) { $match = 'exact'; } return array( 'match' => $match, 'value' => $value ); case 'click': $sel = isset( $config['selector'] ) ? sanitize_text_field( $config['selector'] ) : ''; // 400, not 191: the point-and-click flow reuses the picked // element's generated selector as the click goal, and those // can be longer than a hand-typed class. return array( 'selector' => mb_substr( $sel, 0, 400 ) ); case 'link_click': return array( 'value' => isset( $config['value'] ) ? self::sanitize_path_text( $config['value'], 255 ) : '' ); case 'form': // Optional selector narrowing which form counts; empty = any // form on the page. Same character rules as the visual-test // selector since it also ends up inside the runtime config. return array( 'selector' => self::sanitize_selector( isset( $config['selector'] ) ? $config['selector'] : '' ) ); case 'add_to_cart': // Optional WooCommerce product id; 0 = any product. return array( 'product_id' => isset( $config['product_id'] ) ? absint( $config['product_id'] ) : 0 ); case 'scroll': $p = isset( $config['percent'] ) ? absint( $config['percent'] ) : 50; $p = max( 1, min( 100, $p ) ); return array( 'percent' => $p ); case 'time': $s = isset( $config['seconds'] ) ? absint( $config['seconds'] ) : 30; $s = max( 1, min( 3600, $s ) ); return array( 'seconds' => $s ); case 'active_time': $s = isset( $config['seconds'] ) ? absint( $config['seconds'] ) : 30; $s = max( 1, min( 3600, $s ) ); return array( 'seconds' => $s ); case 'purchase': default: return array(); } } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching