tag, not the bare URL. add_filter( 'seopress_titles_canonical', array( $this, 'filter_canonical_tag' ) ); } /** * Attributes optimisers honour: Cloudflare Rocket Loader, WP Rocket, * Autoptimize, WP Fastest Cache, mod_pagespeed, and the generic pair * several others read. A boolean true prints a bare attribute. * * @return array */ private function optimizer_attributes() { return array( 'data-cfasync' => 'false', 'nowprocket' => true, 'data-noptimize' => true, 'data-no-optimize' => '1', 'data-no-defer' => '1', 'data-wpfc-render' => 'false', 'data-pagespeed-no-defer' => true, ); } /** * The handles of the head's inline style rules (print_style()): the * anti-flicker rule, the no-JavaScript rule, the rule for a visitor the * runtime is off for, the visual tests' hiding rule and the idle rule. */ const HEAD_STYLES = array( 'improveab-hide', 'improveab-nojs', 'improveab-off', 'improveab-visual-hide', 'improveab-idle' ); /** * Print one of the head's inline style rules where it is called (the * top of the head, before the page paints), the way WordPress prints * any inline style: a handle with no file, the rule as its inline style * (wp_add_inline_style()), and wp_print_styles() for that handle alone. * The tag, its id ("{$id}-inline-css") and its escaping are WordPress's * own, so a tool that handles the inline styles WordPress prints (a * nonce-based Content Security Policy, a CSS optimiser's exclusions) * handles these too. * * @param string $id Rule name, one of HEAD_STYLES. * @param string $css Rule text, already escaped or sanitized by the caller. */ private function print_style( $id, $css ) { wp_register_style( $id, false, array(), IMPROVEAB_VERSION ); wp_add_inline_style( $id, $css ); wp_print_styles( $id ); } /** * Keep the head's inline style rules out of a CSS optimiser's combined * file (array form: LiteSpeed Cache matches each entry against the * whole tag). Folded into a stylesheet the optimiser loads without * blocking the first paint, the anti-flicker, failsafe and idle rules * would arrive after it, with every version of a tested element on * screen until they do. * * @param mixed $list Current exclusions. * @return array */ public function exclude_styles( $list ) { $ids = array(); foreach ( self::HEAD_STYLES as $id ) { $ids[] = $id . '-inline-css'; } return array_merge( is_array( $list ) ? $list : array(), $ids ); } /** * The same for Autoptimize (comma form, matched against the whole tag). * * @param mixed $csv Current exclusions. * @return string */ public function exclude_styles_csv( $csv ) { $csv = rtrim( is_string( $csv ) ? $csv : '', ', ' ); return ( '' !== $csv ? $csv . ',' : '' ) . implode( ',', $this->exclude_styles( array() ) ); } /** * Mark the plugin's inline head scripts and its config for optimisers. * * @param array $attributes Attributes of the tag being printed. * @param string $data Script contents. * @return array */ public function inline_attributes( $attributes, $data ) { if ( isset( $attributes['id'] ) && in_array( $attributes['id'], array( 'improveab-pending', 'improveab-redirect', 'improveab-config' ), true ) ) { $attributes = array_merge( $attributes, $this->optimizer_attributes() ); } return $attributes; } /** * Mark the runtime's script tag for optimisers. * * @param string $tag The script tag. * @param string $handle Script handle. * @return string */ public function runtime_tag( $tag, $handle ) { if ( 'improveab-runtime' !== $handle ) { return $tag; } $attrs = ''; foreach ( $this->optimizer_attributes() as $name => $value ) { $attrs .= true === $value ? ' ' . $name : ' ' . $name . '="' . esc_attr( $value ) . '"'; } return str_replace( ' src=', $attrs . ' src=', $tag ); } /** * Add the plugin's scripts to an optimiser's exclusion list (array form). * * @param mixed $list Current exclusions. * @return array */ public function exclude_from_optimizers( $list ) { return array_merge( is_array( $list ) ? $list : array(), array( 'improveab-ab-testing/assets/js/runtime.js', 'improveab-pending', 'improveab-redirect', 'improveab-pending' ) ); } /** * Add the plugin's scripts to an optimiser's exclusion list (comma form). * * @param mixed $csv Current exclusions. * @return string */ public function exclude_csv( $csv ) { $csv = rtrim( (string) $csv, ', ' ); return ( '' !== $csv ? $csv . ',' : '' ) . 'improveab-ab-testing/assets/js/runtime.js,improveab-pending,improveab-redirect'; } /** * Add the runtime handle to an optimiser's exclusion list (handle form). * * @param mixed $handles Current exclusions. * @return array */ public function exclude_handles( $handles ) { $handles = is_array( $handles ) ? $handles : array(); $handles[] = 'improveab-runtime'; return $handles; } /** * What the visitor stores hold for this request, read once * (ImproveAB_Tests::front()), plus the test a manager previews when it * does not run: its pieces built for this request alone. * * @return array */ private function front() { if ( null !== $this->running ) { return $this->running; } $front = ImproveAB_Tests::front(); $front = array_merge( array( 'n' => 0, 'slugs' => array(), 'kept' => '', 'other' => '', 'vis' => array(), 'cfg' => array(), 'url' => array(), 'buy' => array(), 'idle' => '', 'scan' => false ), is_array( $front ) ? $front : array() ); $front['preview'] = null; // A manager previewing a test that does not run (a draft checked // before launch, a paused or finished test): delivered for this one // request as if it ran, so the preview link on its results page shows // the version it names. The runtime treats a preview as forced: it // shows that version and never assigns, counts or attaches goals. $pslug = $this->preview_slug(); if ( '' !== $pslug && improveab_can_manage() && ! in_array( $pslug, (array) $front['slugs'], true ) ) { $test = ImproveAB_Tests::get_by_slug( $pslug ); if ( $test ) { $test['preview'] = true; $front['preview'] = array( 'test' => $test, 'pieces' => self::pieces( $test ), ); } } $this->running = $front; return $front; } /** * The slug of the test a manager is previewing from one of the plugin's * own preview links (improveab_preview_request()), or ''. * * @return string */ private function preview_slug() { $preview = improveab_preview_request(); return $preview ? $preview['slug'] : ''; } /** * One running or served test's share of every visitor page, built once * per cache generation for the visitor store (ImproveAB_Tests::front()), * so a page view joins finished pieces instead of building them: * * - kept / other: an element test's hide selectors, the version kept * when no script runs (the original, or the served winner) and the * others; * - vis: a visual test's selector, sanitised again here, where it is * printed: one a migration or a restore stored must not close the * head's style element; * - cfg: the runtime config's entry as JSON, and for a visual test that * has a page scope the trimmed entry pages outside it get; * - url: a URL test's redirect entry and the paths it names; * - event: what /event checks an event against. * * @param array $test Test with variants (`serve` set for a served winner). * @return array */ public static function pieces( $test ) { $serve = isset( $test['serve'] ) ? (string) $test['serve'] : ''; $pages = isset( $test['targeting']['pages'] ) && is_array( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array(); $out = array( 'kept' => array(), 'other' => array(), 'vis' => null, 'cfg' => null, 'url' => null, 'event' => null, ); if ( 'element' === $test['type'] ) { $keep = '' !== $serve ? $serve : 'default'; foreach ( $test['variants'] as $v ) { if ( empty( $v['class_name'] ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $v['class_name'] ) ) { continue; } $sel = '.' . $v['class_name'] . self::HIDE_WEIGHT; if ( $keep === (string) $v['variant_key'] ) { $out['kept'][] = $sel; } else { $out['other'][] = $sel; } } } $selector = 'visual' === $test['type'] ? ImproveAB_Tests::sanitize_selector( self::visual_selector( $test ) ) : ''; if ( '' !== $selector ) { $out['vis'] = array( 's' => (string) $test['slug'], 'sel' => $selector, 'pg' => $pages, 'serve' => '' !== $serve, ); } $scoped = ! empty( $pages['mode'] ) && 'all' !== $pages['mode'] && ! empty( $pages['patterns'] ); $out['cfg'] = array( 's' => (string) $test['slug'], 'j' => (string) wp_json_encode( self::config_entry( $test, $selector, false ), JSON_HEX_TAG ), // A visual variant never renders outside the test's page scope (the // runtime checks scope at display time), so such pages carry only // what goal tracking needs: no markup. Previews and served // winners render anywhere and keep it all. 't' => 'visual' === $test['type'] && '' === $serve && $scoped ? (string) wp_json_encode( self::config_entry( $test, $selector, true ), JSON_HEX_TAG ) : '', 'pg' => $pages, // A page-visit goal on one exact page, and a page rule that names // a page by its query, need the query vars that pick what // WordPress shows (the config's `qv`), which a page without one // does not carry. 'q' => ( 'url' === $test['goal_type'] && isset( $test['goal_config']['match'] ) && 'exact' === $test['goal_config']['match'] ) || ImproveAB_Tests::pages_have_query( $pages ), ); if ( 'url' === $test['type'] ) { $out['url'] = self::redirect_entry( $test ); } // The variant pages' hosts as /event compares them with the Origin a // browser sends: in the browser's form (an international name as // xn--, whichever way the address was typed). $hosts = array(); $home = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); foreach ( $test['variants'] as $v ) { $vhost = improveab_ascii_host( (string) wp_parse_url( (string) $v['url'], PHP_URL_HOST ) ); if ( 'url' === $test['type'] && '' !== $vhost && $vhost !== $home ) { $hosts[] = improveab_root_domain( $vhost ); } } $out['event'] = array( 'id' => (int) $test['id'], 'k' => array_map( 'strval', wp_list_pluck( $test['variants'], 'variant_key' ) ), 'h' => array_values( array_unique( $hosts ) ), ); return $out; } /** * A test's entry in the runtime config (#improveab-config), the shape * runtime.js reads (CONTRACTS 10.1: it only grows). * * @param array $test Test with variants. * @param string $selector A visual test's selector, sanitised. * @param bool $trim Leave out the markup (a visual test on a * page outside its scope). * @return array */ private static function config_entry( $test, $selector, $trim ) { $variants = array(); foreach ( $test['variants'] as $v ) { $vitem = array( 'key' => $v['variant_key'], 'class' => $v['class_name'], 'url' => (string) $v['url'], ); // Visual tests: ship only what the runtime applies (the // element selector and the replacement text or inline HTML). if ( 'visual' === $test['type'] && ! empty( $v['edits'] ) && is_array( $v['edits'] ) ) { $vitem['edits'] = array( 'selector' => $selector, ); if ( isset( $v['edits']['text'] ) ) { $vitem['edits']['text'] = (string) $v['edits']['text']; } // The markup is filtered again where it is printed, as the // selector is: a row can reach the table another way than a // save (a restore, a search and replace, an // earlier release with a looser filter), and what this prints // is what every visitor's browser runs. A second pass over // what a save stored changes nothing. if ( ! $trim && isset( $v['edits']['mode'] ) && 'html' === $v['edits']['mode'] && isset( $v['edits']['html'] ) ) { $html = ImproveAB_Tests::clean_variant_html( $v['edits']['html'] ); if ( '' !== $html ) { $vitem['edits']['mode'] = 'html'; $vitem['edits']['html'] = $html; } } } $variants[] = $vitem; } $entry = array( 'slug' => $test['slug'], // Tells this test apart from an earlier one with the same slug // (deleted and created again), whose stored assignment and // counted flags a returning visitor still carries. 'uid' => self::test_uid( $test ), 'type' => $test['type'], 'variants' => $variants, // Where the test runs; every visitor except staff takes part. 'targeting' => array( 'pages' => isset( $test['targeting']['pages'] ) && is_array( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array() ), 'goal' => array( 'type' => $test['goal_type'], 'config' => is_array( $test['goal_config'] ) ? $test['goal_config'] : array(), ), ); if ( ! empty( $test['serve'] ) ) { $entry['serve'] = $test['serve']; } // A page rule that names a page by its query: a runtime before level // 2 reads the "?" as part of the path, so an "Everywhere except" // rule would run the test on the very page it leaves out. Such a // runtime, still in a page cache, shows the original instead // (CONTRACTS 10.1 rule 5). if ( ImproveAB_Tests::pages_have_query( isset( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array() ) ) { $entry['rt'] = 2; } return $entry; } /** * A URL test's entry in the redirect script's config, and the paths its * addresses name on this site: the script is printed only on those * pages (redirect_config()). The handoff token for variants on another * host is signed per request (its expiry runs from now). * * @param array $test URL test with variants. * @return array|null */ private static function redirect_entry( $test ) { // Hosts in the form a browser names them: an address pasted from the // browser's bar names an international site in xn-- letters, while // the Home address is kept as it was typed. $home = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $entry = ''; $variants = array(); $cross = false; $paths = array(); $local = array(); foreach ( $test['variants'] as $v ) { $url = (string) $v['url']; $variants[] = array( 'key' => $v['variant_key'], 'url' => $url, ); if ( 'default' === $v['variant_key'] ) { $entry = $url; } // A host the browser cannot name is another host, not none. $vhost = (string) wp_parse_url( $url, PHP_URL_HOST ); if ( '' !== $vhost && improveab_ascii_host( $vhost ) !== $home ) { $cross = true; } elseif ( '' !== $url ) { $paths[] = self::normalize_path( $url ); if ( 'default' !== $v['variant_key'] ) { $local[] = $url; } } } if ( '' === $entry ) { return null; // No entry URL configured; skip. } $item = array( 'slug' => $test['slug'], 'uid' => self::test_uid( $test ), 'entry' => $entry, 'variants' => $variants, ); if ( ! empty( $test['serve'] ) ) { $item['serve'] = $test['serve']; } $entry_host = (string) wp_parse_url( $entry, PHP_URL_HOST ); return array( 'item' => $item, 'paths' => array_values( array_unique( $paths ) ), 'cross' => $cross, 'goal' => array( 'type' => $test['goal_type'], 'config' => is_array( $test['goal_config'] ) ? $test['goal_config'] : array(), ), // The canonical: the control, when the original is on this host, // and the variant addresses on this host it is printed on. 'control' => '' === $entry_host || improveab_ascii_host( $entry_host ) === $home ? $entry : '', 'local' => $local, 'serve' => ! empty( $test['serve'] ), ); } /** * Print early head output: hiding rule for element tests, redirect logic * for URL tests, and a canonical tag on recognised variant pages. */ public function head_snippet() { // An editor canvas (the Customizer, a builder's frame) must show every // variant untouched: no hide rule, no anti-flicker gate, no redirect. // The canonical handling further down is for search engines and is // never printed inside an editor anyway. if ( $this->is_editor_preview() ) { return; } $front = $this->front(); $preview = $front['preview'] ? $front['preview']['pieces'] : null; // The same opt-out filter as the runtime: when disabled, skip assignment // and redirect output. Canonical handling below is left in place because // it is for search engines, not visitor tracking. $enabled = (bool) apply_filters( 'improveab_enabled', true ); $settings = ImproveAB_Settings::get(); if ( ! empty( $settings['exclude_logged_in'] ) && is_user_logged_in() ) { $enabled = false; } // Visual (point-and-click) tests hide only the picked element until // the runtime has applied the assigned text, so visitors never see // the original flash first. visibility (not display) keeps layout // stable. Selectors are sanitized on save and again in pieces(). $visual_css = ''; $visuals = (array) $front['vis']; if ( $preview && $preview['vis'] ) { $visuals[] = $preview['vis']; } if ( $enabled && ! empty( $visuals ) ) { $previewing = $this->preview_slug(); foreach ( $visuals as $vis ) { // Outside the test's page scope the runtime never swaps the // element, so hiding it only held back what is often the // page's largest paint until the runtime ran. if ( empty( $vis['serve'] ) && $previewing !== $vis['s'] && ! $this->page_in_scope( array( 'targeting' => array( 'pages' => $vis['pg'] ) ) ) ) { continue; } $visual_css .= 'html.improveab-pending ' . $vis['sel'] . '{visibility:hidden !important}'; } } // Element tests: the variant classes to hide until the runtime // reveals the assigned one, and the classes to hide when no script // will ever run (JavaScript off, or the runtime switched off for this // visitor): everything except the control, or except the served // winner. Listing the classes (rather than a [class*="improveab-"] // wildcard) means an unrelated plugin or theme using a similar prefix // is never hidden by us. Built regardless of $enabled: an excluded // visitor must still see one version, not every variant stacked. $kept = (string) $front['kept']; $other = (string) $front['other']; if ( $preview ) { $kept = implode( ',', array_filter( array_merge( array( $kept ), $preview['kept'] ) ) ); $other = implode( ',', array_filter( array_merge( array( $other ), $preview['other'] ) ) ); } $all = implode( ',', array_filter( array( $kept, $other ) ) ); if ( $enabled && '' !== $all ) { // When the failsafe below has to act (the runtime is slow or // blocked), the page falls back to what no-JavaScript visitors // see, one version, instead of every variant stacked. $this->print_style( 'improveab-hide', esc_html( 'html.improveab-pending ' . str_replace( ',', ',html.improveab-pending ', $all ) ) . '{display:none !important}' . ( '' !== $other ? esc_html( 'html.improveab-failsafe ' . str_replace( ',', ',html.improveab-failsafe ', $other ) ) . '{display:none !important}' : '' ) ); } if ( '' !== $other ) { $nojs_rule = esc_html( $other ) . '{display:none !important}'; if ( $enabled ) { // Without JavaScript nothing assigns or reveals, so a visitor // would see every variant's markup at once. A noscript // wrapper is the one rule that applies exactly then (and to // a blocker that renders noscript content, as NoScript and // uBlock Origin do); WordPress prints the style inside it. echo '' . "\n"; } else { // Runtime off for this visitor (exclude_logged_in, or the // improveab_enabled filter): no script will hide anything, so // the same control-only rule applies unconditionally. $this->print_style( 'improveab-off', $nojs_rule ); // Escaped above. } } if ( '' !== $visual_css ) { // Selectors sanitised on save and in pieces(), and tags stripped // again here, where the rule is printed (as core prints a theme's // Additional CSS): a combinator or a quote must reach the rule as // it is, so esc_html() would break it. $this->print_style( 'improveab-visual-hide', wp_strip_all_tags( $visual_css ) ); } // Tests that exist but are not running (a draft being prepared, a // paused test, a stopped test whose winner is not being served): the // classes are already on the page, so without a rule every version // would show stacked. Visitors see the original instead, until the // test launches or the page is edited. Printed for every visitor, // runtime or not; deleting a test drops its rule, so remove the // markup first. A short rule is printed whole; past // ImproveAB_Tests::IDLE_INLINE_MAX the page carries the selectors of // the classes in its own markup only (idle_scan(), once the page is // rendered), so a site that keeps every old test does not send them // all with every page. $skip = array(); if ( $front['preview'] ) { foreach ( $front['preview']['test']['variants'] as $v ) { $skip[] = (string) $v['class_name']; } } if ( '' !== $front['idle'] ) { $idle = (string) $front['idle']; foreach ( $skip as $cls ) { $idle = str_replace( array( '.' . $cls . self::HIDE_WEIGHT . ',', ',.' . $cls . self::HIDE_WEIGHT, '.' . $cls . self::HIDE_WEIGHT ), array( '', '', '' ), $idle ); } if ( '' !== $idle ) { $this->print_style( 'improveab-idle', esc_html( $idle ) . '{display:none !important}' ); } } elseif ( ! empty( $front['scan'] ) && $this->buffering ) { $this->idle_skip = $skip; $this->print_style( 'improveab-idle', self::IDLE_MARK ); } elseif ( ! empty( $front['scan'] ) ) { // WordPress keeps no buffer around this page (a version before // 6.9, a site that streams its pages, a head printed outside a // template load): every idle class, as before the scan existed. $sel = array(); foreach ( preg_split( '/\s+/', trim( ImproveAB_Tests::front_idle_classes() ) ) as $cls ) { if ( '' !== $cls && ! in_array( $cls, $skip, true ) ) { $sel[] = '.' . $cls . self::HIDE_WEIGHT; } } if ( $sel ) { $this->print_style( 'improveab-idle', esc_html( implode( ',', $sel ) ) . '{display:none !important}' ); } } // Inline scripts go through wp_print_inline_script_tag() so a // nonce-based CSP plugin can attribute them (wp_inline_script_attributes). if ( $enabled && ( '' !== $all || '' !== $visual_css ) ) { // The inline failsafe below lifts the gate even if runtime.js // never executes (blocked by an ad blocker, a consent tool, or a // broken optimizer), so tested elements can never stay hidden: the // page switches to improveab-failsafe, which shows the original of // each element test (one version, not every variant stacked) until // the runtime, if it ever runs, takes over and clears it. The // runtime normally reveals much sooner, and then this finds // nothing left to do. wp_print_inline_script_tag( 'document.documentElement.className+=" improveab-pending";(function(f){if(document.prerendering){document.addEventListener("prerenderingchange",f,{once:true});}else{f();}})(function(){setTimeout(function(){var h=document.documentElement;if(/\bimproveab-pending\b/.test(h.className)){h.className=h.className.replace(/\bimproveab-pending\b/g,"improveab-failsafe");}},' . (int) ( 2 * self::TIMEOUT_MS ) . ');});', array( 'id' => 'improveab-pending' ) ); } // Never inside an editor's preview frame: the redirect would carry // the Customizer or Elementor canvas away from the page being edited. // Only where a URL test's address is this page: the script acts on a // test's entry page alone, and every other page would carry it for // nothing. $redirect = $enabled ? $this->redirect_config() : null; $script = $redirect ? $this->redirect_script() : ''; if ( '' !== $script ) { // Synchronous head script: assigns and redirects before the body paints. wp_print_inline_script_tag( $script . '(' . wp_json_encode( $redirect, JSON_HEX_TAG ) . ');', array( 'id' => 'improveab-redirect' ) ); } // SEO handling on recognised variant URLs while a test runs. Three // modes: canonical (default, Google's recommendation for split tests: // consolidate signals on the original), noindex (keep variants out of // search entirely, through wp_robots(), so the page has one robots // tag; robots() below), or off (the plugin stays out of SEO // completely). The original page is never touched. $control = $this->canonical_target(); if ( $control && 'canonical' === $this->seo_mode() ) { // Printed here only when no SEO plugin prints one: Yoast, Rank // Math, All in One SEO and SEOPress already print theirs, pointed // at the control by filter_canonical(), and a second tag beside it // is noise at best. $seo_plugin = defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' ); if ( ! $seo_plugin || has_action( 'wp_head', 'rel_canonical' ) ) { remove_action( 'wp_head', 'rel_canonical' ); echo '' . "\n"; } } } /** * Ask WordPress for the rendered page, when the idle rule is too long to * print whole (ImproveAB_Tests::IDLE_INLINE_MAX): idle_scan() filters * WordPress's template enhancement output buffer (WordPress 6.9), which * starts before the template is included because a filter is on it. The * plugin opens no output buffer of its own. The head carries the * placeholder only once that buffer has started; where it does not, the * head carries every idle class instead (print_head()). */ public function start_idle_scan() { if ( is_admin() || $this->is_editor_preview() ) { return; } $front = $this->front(); if ( empty( $front['scan'] ) ) { return; } add_filter( 'wp_template_enhancement_output_buffer', array( $this, 'idle_scan' ) ); add_action( 'wp_template_enhancement_output_buffer_started', array( $this, 'mark_idle_scan' ) ); } /** * WordPress's template enhancement output buffer has started: the page * will reach idle_scan() whole. */ public function mark_idle_scan() { $this->buffering = true; } /** * Put the idle rule in the rendered page: the selectors of the idle * tests' other versions whose classes the page's body carries (the head * names the running tests' own classes, which are never idle). Markup a * script adds after load is not covered this way; a site whose idle rule * is short gets it whole on every page instead. Runs inside WordPress's * output buffer callback, so it prints nothing. * * @param string $html The page. * @return string */ public function idle_scan( $html ) { $html = (string) $html; $at = strpos( $html, self::IDLE_MARK ); if ( false === $at ) { return $html; } $css = ''; $body = stripos( $html, '
front(); $live = ',' . $front['kept'] . ',' . $front['other'] . ','; $found = array(); foreach ( array_unique( $m[0] ) as $cls ) { if ( false === strpos( $live, ',.' . $cls . self::HIDE_WEIGHT . ',' ) && ! in_array( $cls, $this->idle_skip, true ) ) { $found[] = $cls; } } if ( $found ) { $idle = ImproveAB_Tests::front_idle_classes(); $sel = array(); foreach ( $found as $cls ) { if ( false !== strpos( $idle, ' ' . $cls . ' ' ) ) { $sel[] = '.' . $cls . self::HIDE_WEIGHT; } } if ( $sel ) { $css = esc_html( implode( ',', $sel ) ) . '{display:none !important}'; } } } return substr_replace( $html, $css, $at, strlen( self::IDLE_MARK ) ); } /** * Noindex on a recognised variant page while its test runs, when the * site chose that mode: through WordPress's own robots tag (and the SEO * plugins that print theirs from it), so the page carries one. * * @param array $robots Directives. * @return array */ public function robots( $robots ) { if ( 'noindex' === $this->seo_mode() && $this->canonical_target() ) { $robots = (array) $robots; $robots['noindex'] = true; $robots['follow'] = true; unset( $robots['index'], $robots['max-image-preview'] ); } return $robots; } /** * Rank Math's own robots (it prints them itself): noindex as above. * * @param array $robots Directives, name => value. * @return array */ public function rank_math_robots( $robots ) { if ( 'noindex' === $this->seo_mode() && $this->canonical_target() ) { $robots = (array) $robots; $robots['index'] = 'noindex'; } return $robots; } /** * Build the inline redirect configuration: the URL tests whose entry or a * variant address names this request's path (null when none does). The * script itself checks the query too (same()); matching the path alone * here keeps a page cache that ignores query strings serving the test. * * @return array|null */ private function redirect_config() { $front = $this->front(); $urls = (array) $front['url']; if ( $front['preview'] && $front['preview']['pieces']['url'] ) { $urls[] = $front['preview']['pieces']['url']; } $here = $this->current_path(); if ( empty( $urls ) || '' === $here || $this->is_editor_preview() ) { return null; } $endpoint = rest_url( ImproveAB_REST::NS . '/event' ); $tests = array(); foreach ( $urls as $u ) { if ( ! in_array( $here, (array) $u['paths'], true ) ) { continue; } $item = $u['item']; // For tests whose variant pages live on another domain/subdomain, // attach a signed instruction. The variant site replays it to this // endpoint so the conversion is attributed back here. The token is // verifiable only by this site, so it cannot be forged elsewhere. if ( ! empty( $u['cross'] ) ) { $item['x'] = improveab_sign( array( 's' => $item['slug'], 'g' => $u['goal'], 'e' => $endpoint, 'x' => time() + ( 60 * DAY_IN_SECONDS ), ), 'ho' ); } $tests[] = $item; } if ( empty( $tests ) ) { return null; } return array( 'e' => esc_url_raw( $endpoint ), 'ed' => current_user_can( 'edit_posts' ) ? 1 : 0, 'qv' => implode( ' ', $this->content_query_vars() ), 'tests' => $tests, ); } /** * A test's uid: 12 hex characters of its uuid (CONTRACTS 8.1). Tells * this test apart from an earlier one with the same slug (deleted and * created again), whose stored assignment and counted flags a returning * visitor still carries. * * @param array $test Test row. * @return string */ private static function test_uid( $test ) { return substr( str_replace( '-', '', (string) ( isset( $test['uuid'] ) ? $test['uuid'] : '' ) ), 0, 12 ); } /** * The query vars that pick what WordPress shows: a search, a single * post, page or attachment, an archive (post type, category, tag, * taxonomy term, author, date), a later page of a list, a post or its * comments, the 404 page (?error=404), and the query var of every post * type and taxonomy a visitor can reach (?product=, ?product_cat=). * Under plain permalinks each of those pages sits on the home path, and * under pretty ones a site search does, so a request carrying one that * a test's URL does not name is another page than that URL * (url_is_current(), the redirect script's same(), the picker's * testOnPage()). Parameters that only sort the same page (orderby, * order) are not among them, nor are the endpoints that never print a * page head (feed, embed, robots, sitemaps, a preview). * * @return string[] */ private function content_query_vars() { if ( null !== $this->content_vars ) { return $this->content_vars; } $vars = array( 's', 'p', 'page_id', 'name', 'pagename', 'attachment', 'attachment_id', 'subpost', 'subpost_id', 'post_type', 'cat', 'category_name', 'tag', 'taxonomy', 'term', 'author', 'author_name', 'm', 'year', 'monthnum', 'w', 'day', 'hour', 'minute', 'second', 'paged', 'page', 'cpage', 'error' ); foreach ( get_post_types( array(), 'objects' ) as $type ) { if ( is_string( $type->query_var ) && '' !== $type->query_var && is_post_type_viewable( $type ) ) { $vars[] = $type->query_var; } } foreach ( get_taxonomies( array(), 'objects' ) as $tax ) { if ( is_string( $tax->query_var ) && '' !== $tax->query_var && ! empty( $tax->publicly_queryable ) ) { $vars[] = $tax->query_var; } } $this->content_vars = array_values( array_unique( $vars ) ); return $this->content_vars; } /** * The inline redirect runtime (assets/js/redirect.js), a self-contained * function expression invoked with the redirect config. Kept tiny and fail-safe. Mirrors the * runtime's rules: memoised visitor id, storage probe (no persisting * storage = excluded), tolerant query parsing, and carry() so the entry * page's query string (minus the preview params) and hash travel with * the redirect: ad-click ids and UTM tags must reach the variant page. * Parameters the variant URL names itself are not carried: under plain * permalinks the entry's page_id=6 appended after the variant's * page_id=7 would win server-side and bounce the visitor back forever. * Nor is any query var that picks what WordPress shows (C.qv, from * content_query_vars()): it names the entry, and on a variant without * one (the homepage) it would show the entry again. * A variant on another host is never the page being viewed, whatever its * path: a redesign mirrored on a subdomain keeps the entry's paths, and a * path-only comparison left every visitor assigned to it on the original. * A request is a URL's page when the path matches, every parameter the * URL names is present with its value, and it carries no query var from * C.qv the URL does not name: a homepage entry must not catch a site * search, and under plain permalinks every page (?page_id=, ?p=, ?cat=) * sits on the home path. * Stored assignments follow runtime.js: one the test does not have, or * one stored under another test's uid (deleted and created again under * the same name, so the same slug), is dropped with its flags and the * visitor drawn afresh; the uid is written with every assignment. Who * takes part decides who is assigned, never who keeps a variant: a * visitor assigned before signing in is still sent to it, as runtime.js * keeps them in it (and files their conversions under it). * * @return string */ private function redirect_script() { // A file of its own, printed inline: it has to run in the head before // the page paints, so it cannot wait for a request of its own. Only a // URL test's entry page reads it. $script = file_get_contents( plugin_dir_path( IMPROVEAB_FILE ) . 'assets/js/redirect.js' ); return false === $script ? '' : rtrim( $script ); } /** * The control (canonical) URL if the current request is a non-control * variant page of a running URL test, otherwise empty string. * * @return string */ private function canonical_target() { if ( false !== $this->canonical ) { return (string) $this->canonical; } $this->canonical = ''; if ( '' === $this->current_path() ) { return ''; } foreach ( (array) $this->front()['url'] as $u ) { // Only while the test runs. A served winner sends every visitor // from the control to the winner, and a canonical (or noindex) // on the winner pointing back would contradict that redirect; a // manager's preview of a test that does not run changes nothing. // URLs on another host are not pages of this site: they can // neither be the current page nor a canonical target here // (cross-domain tests emit no canonical, by design). if ( ! empty( $u['serve'] ) || '' === $u['control'] ) { continue; } foreach ( (array) $u['local'] as $url ) { if ( $this->url_is_current( (string) $url ) ) { $this->canonical = (string) $u['control']; break 2; } } } return (string) $this->canonical; } /** * Canonical filter for SEO plugins. * * @param string $canonical Their canonical URL. * @return string */ public function filter_canonical( $canonical ) { if ( 'canonical' !== $this->seo_mode() ) { return $canonical; } $control = $this->canonical_target(); return $control ? $control : $canonical; } /** * Canonical filter for SEO plugins that filter the whole tag * rather than the bare URL (SEOPress). * * @param string $html Their canonical tag. * @return string */ public function filter_canonical_tag( $html ) { if ( 'canonical' !== $this->seo_mode() ) { return $html; } $control = $this->canonical_target(); return $control ? '' : $html; } /** * Resolve the SEO handling mode for variant pages, deriving from the * legacy on/off toggle when the mode was never saved explicitly. * * @return string canonical|noindex|off */ private function seo_mode() { $mode = (string) $this->setting( 'seo_mode', '' ); if ( in_array( $mode, array( 'canonical', 'noindex', 'off' ), true ) ) { return $mode; } return $this->setting( 'seo_canonical', 1 ) ? 'canonical' : 'off'; } /** * Normalise a URL to a comparable path (no host, no query, no trailing slash). * * @param string $url URL or path. * @return string */ private static function normalize_path( $url ) { if ( '' === $url ) { return ''; } // Decoded on both sides: the request path arrives percent-encoded, // while a variant URL with a non-ASCII slug may have been pasted as // the browser showed it. page_in_scope() applies the same rule. $path = rawurldecode( (string) wp_parse_url( $url, PHP_URL_PATH ) ); $path = rtrim( $path, '/' ); return '' === $path ? '/' : $path; } /** * Whether a URL names the current request: the same path (trailing * slash ignored), every query parameter the URL carries present with * the same value, and no query var that picks what WordPress shows * (content_query_vars()) on the request that the URL does not name. * Plain permalinks tell pages apart by query alone (?page_id=12), so * the path is not enough, and a site search or, under plain permalinks, * any page is a request on the home path, so a URL for the homepage * must not claim them; extra parameters on the request (a UTM tag) do * not matter. The redirect script and the picker apply the same rule. * * @param string $url URL to compare. * @return bool */ private function url_is_current( $url ) { $here = $this->current_path(); if ( '' === $url || '' === $here || self::normalize_path( $url ) !== $here ) { return false; } return $this->request_names( (string) wp_parse_url( $url, PHP_URL_QUERY ) ); } /** * Whether this request carries a query's parameters: every one it * names, with the same value (both decoded), and no query var that * picks what WordPress shows (content_query_vars()) that it does not * name. Extra parameters (a UTM tag) and their order do not matter. A * URL test's address names the current page by it (url_is_current()), * and so does a page rule's pattern with a query part * (page_in_scope()); the runtime and the redirect script apply the * same rule. * * @param string $query Query string, without its "?". * @return bool */ private function request_names( $query ) { $want = self::query_pairs( (string) $query, true ); if ( null === $want ) { return false; } $have = $this->request_query(); $named = array(); foreach ( $want as $k => $v ) { if ( ! array_key_exists( $k, $have ) || $have[ $k ] !== $v ) { return false; } $named[ self::query_base( $k ) ] = true; } foreach ( array_keys( $have ) as $k ) { $base = self::query_base( $k ); if ( '' !== $base && ! isset( $named[ $base ] ) && in_array( $base, $this->content_query_vars(), true ) ) { return false; } } return true; } /** * This request's query parameters, read once. * * @return array */ private function request_query() { if ( null === $this->request_query ) { // Through esc_url_raw() like the stored variant URL it is compared with. $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( 'http://x' . wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $this->request_query = self::query_pairs( (string) wp_parse_url( $uri, PHP_URL_QUERY ), false ); } return $this->request_query; } /** * A query's parameters as the runtime and the redirect script read * them (decodeURIComponent() on each side of the first "="): a "+" * stays a plus, a bracketed name is a name of its own, the last of a * repeated name wins. PHP's own reading takes "+" for a space and * folds "a[]" into an array, so a page rule naming "?s=c++" was out * of scope here and in scope in the browser, which counted visitors * under a version the page never carried. * * @param string $query Query string, without its "?". * @param bool $strict Null for the whole query when a pair does not * decode (a rule's own query, which then names * nothing, as in the runtime); otherwise that pair * is left out (a request's). * @return array|null Name => value. */ private static function query_pairs( $query, $strict ) { $out = array(); foreach ( explode( '&', $query ) as $pair ) { if ( '' === $pair ) { continue; } $at = strpos( $pair, '=' ); $name = self::uri_component( false === $at ? $pair : substr( $pair, 0, $at ) ); $val = self::uri_component( false === $at ? '' : substr( $pair, $at + 1 ) ); if ( null === $name || null === $val ) { if ( $strict ) { return null; } continue; } $out[ $name ] = $val; } return $out; } /** * One part of a query decoded as decodeURIComponent() decodes it, or * null where that throws: a "%" without two hex digits after it, or * bytes that are not UTF-8. * * @param string $part Encoded text. * @return string|null */ private static function uri_component( $part ) { if ( preg_match( '/%(?![0-9A-Fa-f]{2})/', $part ) ) { return null; } $text = rawurldecode( $part ); return 1 === preg_match( '//u', $text ) ? $text : null; } /** * A parameter's name without its brackets ("a[]" is "a"), as the * runtime's picksOther() reads it. * * @param string|int $name Parameter name. * @return string */ private static function query_base( $name ) { return (string) preg_replace( '/\[.*$/D', '', (string) $name ); } /** * The current request path, normalised. * * @return string */ private function current_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( 'http://x' . wp_unslash( $_SERVER['REQUEST_URI'] ) ) : 'http://x'; return self::normalize_path( $uri ); } /** * Whether this request is an editor's preview frame: the Customizer, * a post preview, or a page builder's editing canvas. * * Only a signed-in user who can edit opens one. The Customizer's frame * is signed (a changeset and its nonce) and core builds it for a user * who can customize; a post preview shows the saved draft to the user * who can edit that post, and every builder's canvas is opened by its * editor. An anonymous request that carries one of their query flags * (`?preview=true`, `?bricks=1`, `?elementor-preview=1`...) is a visitor, * or a page cache fetching the page, asking for it without its runtime * and its hide rules (every version of every element test at once, the * idle ones too); and what they get is everybody's wherever a cache keys * pages without the query string. * * @return bool */ private function is_editor_preview() { if ( null !== $this->is_editor ) { return $this->is_editor; } $is_editor = is_customize_preview(); if ( ! $is_editor && current_user_can( 'edit_posts' ) ) { $is_editor = $this->in_editing_canvas(); } /** * Filter whether this request is an editor canvas that must carry no * visitor runtime, no hiding and no redirect (a builder this plugin * does not recognise). * * @param bool $is_editor Whether the request is an editor frame. */ $this->is_editor = (bool) apply_filters( 'improveab_is_editor', $is_editor ); return $this->is_editor; } /** * Whether a user who can edit is looking at a post preview or a page * builder's editing canvas (is_editor_preview() asks only then). * * @return bool */ private function in_editing_canvas() { if ( is_preview() ) { return true; } if ( class_exists( '\\Elementor\\Plugin' ) && isset( \Elementor\Plugin::$instance->preview ) && is_callable( array( \Elementor\Plugin::$instance->preview, 'is_preview_mode' ) ) && \Elementor\Plugin::$instance->preview->is_preview_mode() ) { return true; } if ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) { return true; } if ( function_exists( 'et_core_is_fb_enabled' ) && et_core_is_fb_enabled() ) { return true; } if ( class_exists( 'FLBuilderModel' ) && is_callable( array( 'FLBuilderModel', 'is_builder_active' ) ) && FLBuilderModel::is_builder_active() ) { return true; } if ( function_exists( 'vc_is_page_editable' ) && vc_is_page_editable() ) { return true; } if ( defined( 'SHOW_CT_BUILDER' ) && SHOW_CT_BUILDER ) { return true; } // Query flags the builders' editor frames carry: Elementor, Bricks, // Etch (its editor is the front-end page itself under ?etch=, with // the canvas in an iframe it fills by script), Divi, Beaver // Builder, Oxygen, Breakdance, WPBakery, Visual Composer, Brizy, // Thrive, Zion, Cornerstone, SiteOrigin. $flags = array( 'elementor-preview', 'bricks', 'etch', 'et_fb', 'fl_builder', 'fl_builder_preview', 'ct_builder', 'ct_inner', 'breakdance', 'breakdance_iframe', 'vc_editable', 'vcv-editable', 'brizy-edit', 'brizy-edit-iframe', 'tve', 'zion_builder_active', 'cornerstone', 'cs_preview', 'siteorigin_panels_live_editor' ); foreach ( $flags as $flag ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only detection of an editor frame. if ( isset( $_GET[ $flag ] ) ) { return true; } } return false; } /** * Whether the current URL is inside a test's page scope, by the same * rules as the runtime's pathMatches() (CONTRACTS targeting.pages): the * pattern's path against the decoded request path, trailing slash * ignored, `*` matching anything; and a pattern with a query part * ("/?page_id=12", which plain permalinks need, where every page's path * is the home path) also names the request's query as a URL test's * address does (request_names()). Depends only on the request's * address, so page caches, which key pages by it, stay correct. * * @param array $test Test with targeting. * @return bool */ private function page_in_scope( $test ) { $pg = isset( $test['targeting']['pages'] ) && is_array( $test['targeting']['pages'] ) ? $test['targeting']['pages'] : array(); if ( empty( $pg['mode'] ) || 'all' === $pg['mode'] || empty( $pg['patterns'] ) || ! is_array( $pg['patterns'] ) ) { return true; } $here = rawurldecode( $this->current_path() ); $hit = false; foreach ( $pg['patterns'] as $p ) { $p = (string) $p; $at = strpos( $p, '?' ); $query = false === $at ? null : (string) substr( $p, $at + 1 ); // A "?" or "#" in the path itself is stored as its escape. $p = str_ireplace( array( '%3F', '%23' ), array( '?', '#' ), false === $at ? $p : substr( $p, 0, $at ) ); $p = strlen( $p ) > 1 ? rtrim( $p, '/' ) : $p; $re = '#^' . str_replace( '\*', '.*', preg_quote( $p, '#' ) ) . '$#u'; if ( preg_match( $re, $here ) && ( null === $query || $this->request_names( $query ) ) ) { $hit = true; break; } } return 'include' === $pg['mode'] ? $hit : ! $hit; } /** * Read a single plugin setting. * * @param string $key Key. * @param mixed $default Fallback. * @return mixed */ private function setting( $key, $default ) { if ( ! class_exists( 'ImproveAB_Settings' ) ) { return $default; } $s = ImproveAB_Settings::get(); return isset( $s[ $key ] ) ? $s[ $key ] : $default; } /** * Enqueue the runtime and localise its configuration (all running tests). */ public function enqueue() { /** * Allow sites to disable the front-end runtime, e.g. to gate it behind * their own consent-management logic. Return false to skip output. * * @param bool $enabled Whether the front-end runtime should load. */ if ( ! apply_filters( 'improveab_enabled', true ) ) { return; } if ( $this->is_editor_preview() ) { return; } $settings = ImproveAB_Settings::get(); if ( ! empty( $settings['exclude_logged_in'] ) && is_user_logged_in() ) { return; } $front = $this->front(); // A site with no tests of its own may still be the far side of a // cross-domain handoff: the visitor arrives with ?abx=, the record is // stored, and the conversion happens pages later (thank-you page). // Only the browser knows a handoff is pending, so once this site has // received one (a page reached through a handoff link) the runtime // loads on every page; before that, only on pages with tests or a // handoff in the address. "A handoff in the address" is one the // runtime would act on (handoff_in_request()), not any text that // holds "abx=": that would let one request from anybody switch the // runtime on for every page and purge every page cache. $has_abx = $this->handoff_in_request(); if ( $has_abx ) { self::handoff_seen(); } /** * Whether to load the runtime on pages with no running test and no * handoff in the URL. Default: whether this site has ever received a * cross-domain handoff (the `improveab_handoff_seen` option). * * @param bool $always Default whether a handoff has arrived here. */ if ( 0 === (int) $front['n'] && ! $front['preview'] && ! $has_abx && ! apply_filters( 'improveab_load_runtime_always', (bool) get_option( 'improveab_handoff_seen' ) ) ) { return; } // Defer: the runtime already waits for DOMContentLoaded (or runs // immediately when the DOM is ready), and the head snippet's inline // failsafe reveals the page even if this file never executes - so a // deferred, non-parser-blocking load changes nothing except letting // the HTML parse without waiting on this download. The array form of // the argument exists since WP 6.3; older versions get the previous // head placement. $script_args = version_compare( get_bloginfo( 'version' ), '6.3', '>=' ) ? array( 'in_footer' => false, 'strategy' => 'defer' ) : false; wp_register_script( 'improveab-runtime', IMPROVEAB_URL . 'assets/js/runtime.js', array(), improveab_asset_ver( 'assets/js/runtime.js' ), $script_args ); $config = array( 'endpoint' => esc_url_raw( rest_url( ImproveAB_REST::NS . '/event' ) ), 'timeout' => self::TIMEOUT_MS, // Hosts a cross-domain handoff (?abx=) may report back to: this // site's own registrable domain, so a redesign on a subdomain // works out of the box, plus any origin the filter names. The // runtime refuses every other endpoint, so a crafted link can // never plant a beacon to an outside host. 'hoHosts' => improveab_trusted_hosts(), // The staff flag lets the runtime decide who takes part at display // time (staff never do), so excluded visitors see the control. // This is the primary guard: event beacons carry no REST nonce, so // they reach the server anonymous and the server-side re-check // only bites when a request is authenticated. 'ed' => current_user_can( 'edit_posts' ) ? 1 : 0, ); $previewing = $this->preview_slug(); // Each test's entry, encoded once per cache generation // (ImproveAB_Tests::front()); a visual test outside its page scope // takes its trimmed entry. $entries = (array) $front['cfg']; if ( $front['preview'] ) { $entries[] = $front['preview']['pieces']['cfg']; } $parts = array(); $exact = false; foreach ( $entries as $e ) { $trim = '' !== $e['t'] && $previewing !== $e['s'] && ! $this->page_in_scope( array( 'targeting' => array( 'pages' => $e['pg'] ) ) ); $parts[] = $trim ? $e['t'] : $e['j']; $exact = $exact || ! empty( $e['q'] ); } // Printed by print_config() as a JSON script, not wp_localize_script // (it casts top-level scalars to strings, and the runtime's staff gate // reads cfg.ed numerically) and not inline JavaScript (a strict CSP // blocks that; JSON is inert data). The tests' entries are already // JSON, so the object is closed around them in the order 1.0.0 // printed: endpoint, timeout, hoHosts, ed, li, tests, purchase, qv. $json = (string) wp_json_encode( $config, JSON_HEX_TAG ); $json = substr( $json, 0, -1 ) . ',"tests":[' . implode( ',', $parts ) . ']'; $purchase = $this->purchase_payload(); if ( null !== $purchase ) { $json .= ',"purchase":' . wp_json_encode( $purchase, JSON_HEX_TAG ); } // The query vars that pick what WordPress shows, as the redirect // script gets them, only while a page-visit goal names one page // exactly or a page rule names a page by its query: a search, or // under plain permalinks another page, on that page's path is not // that page (runtime.js urlGoalHit() and pathMatches()). if ( $exact ) { $json .= ',"qv":' . wp_json_encode( implode( ' ', $this->content_query_vars() ), JSON_HEX_TAG ); } $this->config = $json . '}'; wp_enqueue_script( 'improveab-runtime' ); } /** * Print the runtime configuration as an inert JSON script the runtime * reads with JSON.parse. JSON_HEX_TAG keeps "<" out of the tag body on * top of json_encode's slash escaping, so variant markup can never * close the element. */ public function print_config() { if ( null === $this->config ) { return; } // Escaped here, where it is printed: the fragments were encoded with // JSON_HEX_TAG when the cache was built, and a < or > that reached the // store any other way is written as the same character's \u escape, // which reads back the same and can neither close the tag nor open // a comment. Valid JSON has neither outside a string. wp_print_inline_script_tag( str_replace( array( '<', '>' ), array( '\u003C', '\u003E' ), $this->config ), array( 'type' => 'application/json', 'id' => 'improveab-config' ) ); } /** * Whether this request carries a cross-domain handoff the runtime would * act on: ?abx=, ?abv= and ?abvid= (strings), the instruction decoding to * an object that names a test and an https ImproveAB event route on a * host this site trusts for handoffs (improveab_trusted_hosts(), the * runtime's `hoHosts`) other than this page's own, and not past its * expiry. The same judgement runtime.js makes, except for the signature, * which only the site that minted the instruction can check. It does not * make a crafted link harmless (anyone can build one that reads this * way); it keeps text that merely holds "abx=" from counting, and what a * link makes the page do is the runtime's and the origin's to refuse. * * @return bool */ private function handoff_in_request() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a read-only look at the address, as the runtime does: the value is decoded and judged below, never printed or stored. if ( ! isset( $_GET['abx'], $_GET['abv'], $_GET['abvid'] ) || ! is_string( $_GET['abx'] ) || ! is_string( $_GET['abv'] ) || ! is_string( $_GET['abvid'] ) || '' === $_GET['abx'] || '' === $_GET['abv'] || '' === $_GET['abvid'] ) { return false; } // A token is base64url, a dot and hex, which the sanitizer leaves as // it is. $token = sanitize_text_field( wp_unslash( $_GET['abx'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( strlen( $token ) > 4096 ) { return false; } $head = explode( '.', $token ); $json = base64_decode( strtr( $head[0], '-_', '+/' ), false ); // Lenient, as the runtime's atob() is. $obj = false === $json ? null : json_decode( $json, true ); if ( ! is_array( $obj ) || empty( $obj['s'] ) || ! is_string( $obj['s'] ) || empty( $obj['e'] ) || ! is_string( $obj['e'] ) ) { return false; } if ( isset( $obj['x'] ) && is_numeric( $obj['x'] ) && (int) $obj['x'] <= time() ) { return false; } if ( ! preg_match( '#^https://[^?\#]*(/improveab/v1/event/?$|[?&]rest_route=/improveab/v1/event$)#i', $obj['e'] ) ) { return false; } // Hosts in the form a browser names them (an international name as // xn--), as the runtime reads them from the URL parser. $host = improveab_ascii_host( (string) wp_parse_url( $obj['e'], PHP_URL_HOST ) ); if ( '' === $host ) { return false; } $own = improveab_ascii_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); if ( preg_replace( '/^www\./', '', $host ) === preg_replace( '/^www\./', '', $own ) ) { return false; } return improveab_host_within( $host, improveab_trusted_hosts() ); } /** * Note that this site has received a cross-domain handoff (CONTRACTS 3, * `improveab_handoff_seen`): from then on the runtime loads on every page, * since the conversion may come pages after the visitor arrived. Written * once; the page caches are asked to let go of pages rendered without * the runtime. */ public static function handoff_seen() { if ( get_option( 'improveab_handoff_seen' ) ) { return; } if ( add_option( 'improveab_handoff_seen', (string) time(), '', true ) && class_exists( 'ImproveAB_Cache' ) ) { ImproveAB_Cache::purge_pages( 'handoff' ); } } /** * Build the purchase payload for the runtime. * * @return array|null */ private function purchase_payload() { // Detect the WooCommerce order-received page during enqueue and read the // order total from the order-received query var. The order key from the // URL must match the order (the same check WooCommerce uses before // showing order details), so totals cannot be read — nor revenue tokens // minted — for arbitrary order ids. Real thank-you URLs always carry it. if ( null === $this->purchase_total && function_exists( 'is_order_received_page' ) && is_order_received_page() ) { $order_id = absint( get_query_var( 'order-received' ) ); if ( $order_id && function_exists( 'wc_get_order' ) ) { $order = wc_get_order( $order_id ); $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public thank-you page; the order key is the credential. // An order counts once it is paid (decision 22). An order // WooCommerce counts as paid has its revenue signed (its total // less refunds); one not paid yet (a bank transfer on hold, a // gateway that confirms after the redirect, a failed or // cancelled payment the shopper can still make through the Pay // link) gets a proof that keeps the visitor's place on the // order, and converts when it is paid. So placing and // abandoning orders converts nothing. An order refunded in full // or in the trash is no purchase at all. if ( $order && '' !== $key && hash_equals( (string) $order->get_order_key(), $key ) && ! $order->has_status( array( 'refunded', 'trash' ) ) ) { $this->purchase_paid = $order->is_paid(); $this->purchase_total = $this->purchase_paid ? max( 0, (float) $order->get_total() - (float) $order->get_total_refunded() ) : 0.0; $this->purchase_order_id = $order_id; } } } if ( null === $this->purchase_total ) { return null; } // The proof names the running tests with the purchase goal: a token // lifted from this page credits none of the site's other tests. // `paid` (additive) tells the runtime whether this page's order // counts now. One still waiting for its payment converts when it is // paid, so its visitor stays free to convert meanwhile; a handoff's // conversion, which no proof can follow, counts a paid order only. $front = $this->front(); $tests = array_map( 'intval', (array) $front['buy'] ); $value = round( $this->purchase_total, 2 ); if ( empty( $tests ) ) { return array( 'value' => $value, 'paid' => $this->purchase_paid, ); } // Short-lived: the conversion beacon fires seconds after the thank-you // page renders, so an hour is generous while making the token useless // for later replay farming. A paid order with nothing to pay (a full // discount) carries the waiting proof too, which credits a paid order // at once. if ( $value <= 0 ) { return array( 'value' => 0, 'sig' => improveab_sign( array( 'o' => (int) $this->purchase_order_id, 't' => $tests, 'x' => time() + HOUR_IN_SECONDS ), 'pend' ), 'paid' => $this->purchase_paid, ); } // Sign the amount so the runtime can prove to the event endpoint that it // came from the server (the order total), not from a tampered client. return array( 'value' => $value, 'sig' => improveab_sign( array( 'v' => $value, 'o' => (int) $this->purchase_order_id, 't' => $tests, 'x' => time() + HOUR_IN_SECONDS ), 'rev' ), 'paid' => true, ); } /** * The element selector of a visual test (stored on every variant; the * first one wins). * * @param array $test Test with variants. * @return string */ private static function visual_selector( $test ) { foreach ( (array) $test['variants'] as $v ) { if ( ! empty( $v['edits'] ) && is_array( $v['edits'] ) && ! empty( $v['edits']['selector'] ) ) { return (string) $v['edits']['selector']; } } return ''; } /** * The full goal catalog for the picker: every goal type the plugin * knows, with its label and whether this site has switched it off. * * @return array */ private function goal_catalog() { $avail = improveab_goal_types(); $all = array( 'click' => __( 'Clicks this element', 'improveab-ab-testing' ), 'url' => __( 'Visits a page', 'improveab-ab-testing' ), 'link_click' => __( 'Clicks a link', 'improveab-ab-testing' ), 'form' => __( 'Submits a form', 'improveab-ab-testing' ), 'add_to_cart' => __( 'Adds a product to the cart (WooCommerce)', 'improveab-ab-testing' ), 'scroll' => __( 'Scrolls the page', 'improveab-ab-testing' ), 'time' => __( 'Stays on the page', 'improveab-ab-testing' ), 'active_time' => __( 'Is active on the page', 'improveab-ab-testing' ), 'purchase' => __( 'Completes a purchase (WooCommerce)', 'improveab-ab-testing' ), ); $out = array(); foreach ( $all as $gid => $glabel ) { $out[] = array( 'id' => $gid, 'label' => $glabel, 'off' => ! in_array( $gid, $avail, true ), ); } return $out; } /** * Admin-bar menu for managers on the front end: start a point-and-click * test on the current page, see or exit an active variant preview, and * jump to any test by its state (Running now, Paused, Drafts, Stopped), * then All tests, New test and Settings. Styled by * assets/css/adminbar.css. * * The menu is one node with its own dropdown: a nav of plain links with * group headings, in a .ab-sub-wrapper of the node's HTML. Child nodes * would each get WordPress's ARIA menu roles (6.5 and later), and the * headings would be read as menu items. As a .menupop the node keeps * WordPress's own hover intent, Enter to open and Esc to close (the * links carry .ab-item for that); the bar's script gives its link the * aria-expanded WordPress then keeps in step (see * enqueue_admin_bar_style()). The hooks picker.js relies on stay: * #wp-admin-bar-improveab-new holds the link that starts picking, each * test is an li #wp-admin-bar-improveab-t{id}, and its type tag is * .improveab-ab-tag, which the picker rewrites to "On this page" after * adding .improveab-ab-onpage to the li. * * @param WP_Admin_Bar $bar Admin bar instance. */ public function admin_bar( $bar ) { // Builder editor frames get neither the menu nor the picker it drives. if ( is_admin() || ! improveab_can_manage() || $this->is_editor_preview() ) { return; } $running = ImproveAB_Tests::running_with_variants(); $count = count( $running ); // Brand mark, label, and a running-count pill. The pill is hidden from // screen readers; the node's title attribute says the same in words. $title = '' . esc_html__( 'A/B Tests', 'improveab-ab-testing' ) . ''; if ( $count > 0 ) { $title .= ''; } $bar->add_node( array( 'id' => 'improveab', 'title' => $title, 'href' => admin_url( 'admin.php?page=' . ImproveAB_Admin::SLUG ), 'meta' => array( 'class' => 'menupop', /* translators: %d: number of running tests. */ 'title' => sprintf( _n( '%d running test', '%d running tests', $count, 'improveab-ab-testing' ), $count ), 'html' => '. */ 'htmlReshaped' => __( 'The browser reads %s differently from how it is written here. Check that tag, or undo.', 'improveab-ab-testing' ), // A big block: a neutral note with a way to a smaller part. /* translators: %1$s: "This block holds 14 elements"; %2$s: "6 with text you can edit". */ 'bigLead' => __( '%1$s, and %2$s.', 'improveab-ab-testing' ), /* translators: %1$s: "This block holds 14 elements"; %2$s: "6 with text you can edit". */ 'bigLeadLinks' => __( '%1$s, links and buttons among them, and %2$s.', 'improveab-ab-testing' ), 'bigRisk' => __( 'Changing them all at once can break a menu, a form or add to cart. A heading, a paragraph or one button is safer to test.', 'improveab-ab-testing' ), 'bigKept' => __( 'A big block: preview each variant on the page before you start.', 'improveab-ab-testing' ), 'pickSmallerPart' => __( 'Pick a smaller part', 'improveab-ab-testing' ), 'keepBlock' => __( 'Keep this block', 'improveab-ab-testing' ), /* translators: %1$s: "Show 4 more fields"; %2$s: "1 changed". */ 'moreFieldsJoin' => _x( '%1$s, %2$s', 'more fields and how many changed', 'improveab-ab-testing' ), 'fewerFields' => __( 'Show fewer fields', 'improveab-ab-testing' ), 'errName' => __( 'Give the test a name.', 'improveab-ab-testing' ), /* translators: %s: the variant letter, e.g. C. */ 'errVariant' => __( 'Variant %s is empty. Write its text, or remove it.', 'improveab-ab-testing' ), // The server keeps the first 20,000 characters of a variant's // HTML, so a bigger one is refused before it is cut. /* translators: %1$s: a variant's name, e.g. Variant B; %2$s: how many characters its HTML has; %3$s: the most a variant holds (20,000). */ 'errSize' => __( '%1$s is too big to save: its HTML has %2$s characters, and a variant holds %3$s. Pick a smaller part of the page.', 'improveab-ab-testing' ), 'errGoal' => __( 'Choose a goal to start the test.', 'improveab-ab-testing' ), 'errPath' => __( 'Add a page path.', 'improveab-ab-testing' ), /* translators: %1$s: the site's home path, e.g. /. %2$s: an example of a page's address there, e.g. /?page_id=12. */ 'errPagesEvery' => __( 'Every page on this site has the path %1$s, so this line matches every page. Add the page’s query, for example %2$s.', 'improveab-ab-testing' ), 'errEveryPage' => __( 'This matches every page, so every visitor would count as soon as they arrive. Pick the goal page from the list, or choose Path is exactly.', 'improveab-ab-testing' ), 'starting' => __( 'Starting…', 'improveab-ab-testing' ), 'nmImageBox' => __( 'Image box', 'improveab-ab-testing' ), 'nmIconBox' => __( 'Icon box', 'improveab-ab-testing' ), 'nmCta' => __( 'Call to action', 'improveab-ab-testing' ), 'nmList' => __( 'List', 'improveab-ab-testing' ), 'nmForm' => __( 'Form', 'improveab-ab-testing' ), 'nmField' => __( 'Field', 'improveab-ab-testing' ), 'nmMenu' => __( 'Menu', 'improveab-ab-testing' ), 'nmTestimonial' => __( 'Testimonial', 'improveab-ab-testing' ), 'nmPriceTable' => __( 'Price table', 'improveab-ab-testing' ), 'nmColumns' => __( 'Columns', 'improveab-ab-testing' ), 'nmColumn' => __( 'Column', 'improveab-ab-testing' ), 'nmCover' => __( 'Cover', 'improveab-ab-testing' ), 'nmButtons' => __( 'Buttons', 'improveab-ab-testing' ), 'nmQuote' => __( 'Quote', 'improveab-ab-testing' ), 'nmMediaText' => __( 'Media and text', 'improveab-ab-testing' ), 'nmTable' => __( 'Table', 'improveab-ab-testing' ), 'nmGallery' => __( 'Gallery', 'improveab-ab-testing' ), 'nmSection' => __( 'Section', 'improveab-ab-testing' ), 'nmHeader' => __( 'Header', 'improveab-ab-testing' ), 'nmFooter' => __( 'Footer', 'improveab-ab-testing' ), 'nmElement' => __( 'Element', 'improveab-ab-testing' ), 'advSummarySplit' => __( 'Traffic split and selector', 'improveab-ab-testing' ), 'splitEvenHelp' => __( 'Visitors split evenly between the versions.', 'improveab-ab-testing' ), ), ) ); } }