$whole ) { if ( $rev === $suffix || 0 === strpos( $rev, $suffix . '.' ) ) { // One label past the suffix is the customer's; a few platforms // put more of the customer's own address before it (an app's // region or bucket), and there every host is its own root. $keep = $whole ? $count : substr_count( $suffix, '.' ) + 2; break; } } $shared = improveab_public_suffix_labels( $host ); if ( $shared > 0 ) { $keep = max( $keep, $shared + 1 ); } elseif ( 0 === $keep ) { // No list to read: the older guess. $keep = 2; if ( $count > 2 ) { $tld = $parts[ $count - 1 ]; $second = $parts[ $count - 2 ]; $generic = array( 'co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne', 'go', 'gob', 'gv', 'mil', 'nom', 'ltd', 'plc', 'sch', 'info', 'biz', 'gen', 'int', 'me' ); if ( 2 === strlen( $tld ) && in_array( $second, $generic, true ) ) { $keep = 3; } } } if ( $count > $keep ) { $root = implode( '.', array_slice( $parts, -$keep ) ); } } /** * Filter the resolved root domain (e.g. to handle a public suffix the * lists do not know about). * * @param string $root Resolved root domain. * @param string $host Normalised input host. */ return (string) apply_filters( 'improveab_root_domain', $root, $host ); } /** * How many of a host's right-hand labels are a public suffix: the names * under which other people register or host sites of their own (com, co.uk, * web.id, github.io). The Public Suffix List's algorithm (publicsuffix.org/ * list): of the rules that match, an exception rule wins, else the one with * the most labels, else the default rule "*" (one label). The registrable * domain is that many labels and one more. * * The rules are includes/data/public-suffix-list.php (both of the list's * sections), grouped by top-level domain: each group is one string of the * rules under it, without that last label and with the rest read from the * top down, joined by "/", so a lookup searches a short string and a host * whose TLD has no group is on the default rule. The host is read in its * ASCII form (an IDN as the browser sends it, xn--) and must be plain * letters, digits, hyphens and underscores in dot-separated labels. * * @param string $host A host, lower case, no port. * @return int The suffix's label count, or 0 when the host or the list * cannot be read (the caller then guesses). */ function improveab_public_suffix_labels( $host ) { static $rules = null; if ( null === $rules ) { $file = IMPROVEAB_DIR . 'includes/data/public-suffix-list.php'; $data = is_readable( $file ) ? include $file : array(); $rules = is_array( $data ) ? $data : array(); } $host = rtrim( strtolower( (string) $host ), '.' ); if ( ! $rules || '' === $host ) { return 0; } // A browser sends and reports an IDN in its xn-- form; the list is in it. $ascii = improveab_ascii_host( $host ); if ( ! preg_match( '/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/', $ascii ) || count( explode( '.', $ascii ) ) !== count( explode( '.', $host ) ) ) { return 0; } $labels = explode( '.', $ascii ); $count = count( $labels ); $tld = $labels[ $count - 1 ]; if ( ! isset( $rules[ $tld ] ) || ! is_string( $rules[ $tld ] ) ) { return 1; // No rule has more than the TLD: the default rule. } $group = ' ' . $rules[ $tld ] . ' '; $best = 1; $cut = 0; // A candidate of k labels is the last k; without its TLD it is the k - 1 // labels to the left, which the data writes from the top down with a slash between them. Its rule // may be those labels, or the same with the leftmost one a "*"; an // exception is the rule with a "!". No rule has more than 16 labels // (the list is generated without one). for ( $k = 2, $max = min( $count, 16 ); $k <= $max; $k++ ) { $down = array_reverse( array_slice( $labels, $count - $k, $k - 1 ) ); $exact = implode( '/', $down ); $wild = implode( '/', array_merge( array_slice( $down, 0, -1 ), array( '*' ) ) ); if ( false !== strpos( $group, ' ' . $exact . ' ' ) || false !== strpos( $group, ' ' . $wild . ' ' ) ) { $best = $k; } if ( false !== strpos( $group, ' !' . $exact . ' ' ) ) { // An exception rule: the suffix is the rule without its first label. $cut = $k - 1; } } return $cut > 0 ? $cut : $best; } /** * A host in the form a browser names it: lower case, no trailing dot, and an * international name as its ASCII (xn--) labels. * * A browser sends `Origin: https://xn--bcher-kva.de` and reports * `location.hostname` that way, whichever way the address was typed, while the * site's Home and Site addresses, a variant's address and a filter's host are * kept as typed, and an international name is often typed in Unicode * (WordPress does not convert it). Every comparison of a host the browser * named with one the site holds is made on this form, so a site stored as * `http://bücher.de` still takes its own pages for its own. * * Plain ASCII passes through, lower-cased. A name with other characters is * converted by PHP's intl extension (UTS 46, the processing browsers do) or, * where intl is missing, lower-cased and encoded by the Punycode encoder * WordPress ships for its HTTP requests, which maps nothing beyond the * lower-casing: a name typed in a form a browser would normalise first * (decomposed accents, say) then matches no Origin, which fails closed. * * The name is whatever its sender chose (an Origin header on the public * event route, the host in an `abx` link, a manager's address), and that * encoder's work grows with the square of the different letters in a label: * 8 KB of them cost 115 ms a call. So the work is bounded before anything is * converted, and a name no host can be is none: more than 1,012 bytes (a host * has at most 253 octets, each from at most four bytes), a label of more * than 63 letters, or more than 127 labels. Real names are far inside all * three (a label of 57 letters is already 63 octets as a browser names it), * but the form typed can be longer than the form named, so the limit on the * typed form is what no valid name can exceed, not 253. Characters a browser * drops while it maps a name (soft hyphens, variation selectors) count * here: a name stuffed with them is none. * * @param string $host A host without a port, as wp_parse_url() returns it. * @return string The host, or '' when it is empty or has characters that no * host name can hold (so it matches nothing). */ function improveab_ascii_host( $host ) { $host = rtrim( trim( (string) $host ), '.' ); if ( ! preg_match( '/[^\x00-\x7F]/', $host ) ) { return strtolower( $host ); } if ( strlen( $host ) > 1012 || substr_count( $host, '.' ) > 126 ) { return ''; } foreach ( explode( '.', $host ) as $label ) { // A label of 63 bytes or fewer cannot hold more than 63 letters. if ( strlen( $label ) > 63 && mb_strlen( $label, 'UTF-8' ) > 63 ) { return ''; } } $ascii = ''; if ( function_exists( 'idn_to_ascii' ) && defined( 'INTL_IDNA_VARIANT_UTS46' ) ) { $converted = idn_to_ascii( $host, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46 ); $ascii = is_string( $converted ) ? $converted : ''; } elseif ( preg_match( '//u', $host ) ) { $encoder = class_exists( 'WpOrg\Requests\IdnaEncoder' ) ? 'WpOrg\Requests\IdnaEncoder' : ( class_exists( 'Requests_IDNAEncoder' ) ? 'Requests_IDNAEncoder' : '' ); if ( '' !== $encoder ) { try { $ascii = (string) $encoder::encode( function_exists( 'mb_strtolower' ) ? mb_strtolower( $host, 'UTF-8' ) : $host ); } catch ( Exception $e ) { $ascii = ''; } } } $ascii = strtolower( $ascii ); return preg_match( '/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/', $ascii ) ? $ascii : ''; } /** * Whether a host is one of the given hosts or a subdomain of one, compared * in the form a browser names hosts (improveab_ascii_host()) on both sides. * * @param string $host A host. * @param string[] $hosts Hosts; each also covers its subdomains. * @return bool */ function improveab_host_within( $host, $hosts ) { $host = improveab_ascii_host( $host ); if ( '' === $host ) { return false; } foreach ( (array) $hosts as $h ) { $h = improveab_ascii_host( $h ); if ( '' !== $h && ( $host === $h || substr( $host, -strlen( $h ) - 1 ) === '.' . $h ) ) { return true; } } return false; } /** * The hosts this site trusts as its own beyond its exact address: where a * cross-domain handoff arriving here may report back to (the runtime's * `hoHosts`), and the pages /event takes events from (CONTRACTS 5.1). Each * entry also covers its subdomains. By default the site's registrable * domain; the `improveab_handoff_hosts` filter adds others (an original on * another domain, pages of this site served on a second domain). An * international name is listed as typed and in the form a browser names it * (improveab_ascii_host()): the runtime's list is compared with hosts a URL * parser has made ASCII. * * @param bool $with_own Also the home and site addresses' hosts. * @return string[] */ function improveab_trusted_hosts( $with_own = false ) { /** * Filter the hosts this site trusts as its own: where a cross-domain * handoff arriving here may report back to, and the pages /event accepts * events from. Each entry also covers its subdomains. * * @param string[] $hosts Default: the site's registrable domain. */ $hosts = array_map( 'strval', (array) apply_filters( 'improveab_handoff_hosts', array( improveab_root_domain() ) ) ); if ( $with_own ) { // The home and site addresses, not the Host header of the request: // the sender chooses that (any name that points at this server, a // default virtual host, DNS rebinding), so an Origin on "the host I // sent" proved nothing. A site that answers pages under another name // lists it with improveab_handoff_hosts, as the event endpoint is // the home address's whichever name a page was served under. $hosts[] = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $hosts[] = (string) wp_parse_url( site_url(), PHP_URL_HOST ); } $out = array(); foreach ( $hosts as $h ) { $out[] = strtolower( $h ); $out[] = improveab_ascii_host( $h ); } return array_values( array_unique( array_filter( $out ) ) ); } /** * The private suffixes improveab_root_domain() treats like a country's * second level: platforms where every customer gets a subdomain of their * own. Labels reversed (top first), longest first so a deeper suffix wins * ("app.railway.up" before "app.railway"); true marks the platforms where * every host is a customer's own root, because the customer's name sits * more than one label before the platform's. * * @return array */ function improveab_platform_suffixes() { static $list = null; if ( null !== $list ) { return $list; } $list = array( // WordPress hosts: temporary, staging and site addresses. 'com.wpengine', 'com.wpenginepowered', 'cloud.kinsta', 'app.kinsta', 'io.pantheonsite', 'com.flywheelsites', 'com.flywheelstaging', 'com.myftpupload', 'com.cloudwaysapps', 'com.wpcomstaging', 'com.wordpress', 'xyz.instawp', 'co.instawp', 'link.instawp', 'com.hostingersite', 'com.sg-host', 'domains.temp', 'com.mystagingwebsite', 'me.mybluehost', 'com.dreamhosters', 'io.nxcli', 'net.nxcli', 'site.onrocket', 'link.runcloud', 'io.localsite', 'site.platformsh', 'com.acquia-sites', 'io.strattic', 'com.godaddysites', // App, static and site-builder hosts. 'com.herokuapp', 'com.appspot', 'app.web', 'com.firebaseapp', 'com.onrender', 'dev.fly', 'app.vercel', 'sh.now', 'app.netlify', 'com.netlify', 'dev.pages', 'dev.workers', 'io.github', 'io.gitlab', 'net.azurewebsites', 'net.azurestaticapps', 'net.cloudapp', 'com.blogspot', 'com.wixsite', 'com.squarespace', 'io.webflow', 'me.glitch', 'co.repl', 'app.replit', 'dev.replit', 'sh.surge', 'app.ondigitalocean', 'app.railway.up', 'app.railway', 'dev.deno', 'app.koyeb', 'io.stackblitz', 'app.csb', 'io.codesandbox', 'com.amplifyapp', 'net.cloudfront', 'com.carrd', 'com.tumblr', 'com.weebly', // Tunnels that put a development site online. 'io.ngrok', 'app.ngrok', 'dev.ngrok', 'app.ngrok-free', 'dev.ngrok-free', 'com.trycloudflare', 'lt.loca', 'me.localtunnel', ); $list = array_fill_keys( $list, false ); // The customer's name sits further in on these (bucket.s3.amazonaws.com, // env.region.elasticbeanstalk.com, service-hash.a.run.app). foreach ( array( 'com.amazonaws', 'com.elasticbeanstalk', 'app.run' ) as $improveab_whole ) { $list[ $improveab_whole ] = true; } uksort( $list, static function ( $a, $b ) { return substr_count( $b, '.' ) - substr_count( $a, '.' ); } ); return $list; } /** * The key one purpose of signed token is signed with. Each purpose has a key * of its own, derived from the site's auth salt by HMAC under the purpose's * label: a token minted for one purpose never verifies as another, and the * login-cookie salt itself never signs anything a visitor carries. * Site-specific; never leaves the server. * * Purposes: `ho` (a cross-domain handoff), `rev` (a paid order's revenue), * `pend` (an order whose payment is still to come). * * @param string $type Purpose. * @return string */ function improveab_secret( $type ) { return hash_hmac( 'sha256', 'improveab-token|' . (string) $type, wp_salt( 'auth' ) ); } /** * Sign a small array into a compact, URL-safe, tamper-evident token. * * Format: base64url(json) . "." . hmac_sha256(base64url, key of the * purpose). The payload names its purpose in `y`. Only this site can mint * a token its own key will verify, so a token is implicitly bound to the * site that issued it. * * @param array $data Data to sign (keep it small). * @param string $type Purpose (see improveab_secret()). * @return string */ function improveab_sign( array $data, $type ) { $data = array( 'y' => (string) $type ) + $data; $b64 = rtrim( strtr( base64_encode( (string) wp_json_encode( $data ) ), '+/', '-_' ), '=' ); $sig = hash_hmac( 'sha256', $b64, improveab_secret( $type ) ); return $b64 . '.' . $sig; } /** * Verify and decode a token produced by improveab_sign() for one purpose. * * @param mixed $token Token string. * @param string $type The purpose it must have been signed for. * @return array|false Decoded data, or false if invalid, expired or minted * for another purpose. */ function improveab_verify( $token, $type ) { // Legitimate tokens are small (a slug, a goal config, an endpoint URL and // an expiry). Reject anything oversized before doing HMAC/base64/JSON // work, since tokens arrive on a public, unauthenticated endpoint. if ( ! is_string( $token ) || strlen( $token ) > 4096 || false === strpos( $token, '.' ) ) { return false; } list( $b64, $sig ) = explode( '.', $token, 2 ); $expected = hash_hmac( 'sha256', $b64, improveab_secret( $type ) ); if ( ! hash_equals( $expected, (string) $sig ) ) { return false; } $json = base64_decode( strtr( $b64, '-_', '+/' ), true ); $data = is_string( $json ) ? json_decode( $json, true ) : null; if ( ! is_array( $data ) || ! isset( $data['y'] ) || (string) $type !== $data['y'] ) { return false; } if ( isset( $data['x'] ) && time() > (int) $data['x'] ) { return false; } return $data; } /** * --------------------------------------------------------------------------- * Capability layer. * * There are no built-in limits. These filters let a site set its own ceilings. * --------------------------------------------------------------------------- */ /** * Whether the current user may manage tests. * * @return bool */ function improveab_can_manage() { return current_user_can( 'manage_options' ) || current_user_can( IMPROVEAB_CAP ); } /** * The variant preview a manager opened from one of the plugin's own preview * links (?improveab=slug&improveab_variant=key&improveab_nonce=...), or * null. The server shows a test that does not run, or keeps a previewed * test whole on a page outside its scope, only for this; a running test's * preview is the visitor runtime's alone and needs nothing from the server. * * @return array{slug: string, variant: string}|null */ function improveab_preview_request() { if ( ! isset( $_GET['improveab_nonce'] ) || ! is_string( $_GET['improveab_nonce'] ) ) { return null; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['improveab_nonce'] ) ), 'improveab_preview' ) || ! improveab_can_manage() ) { return null; } $slug = isset( $_GET['improveab'] ) && is_string( $_GET['improveab'] ) ? sanitize_title( wp_unslash( $_GET['improveab'] ) ) : ''; $variant = isset( $_GET['improveab_variant'] ) && is_string( $_GET['improveab_variant'] ) ? sanitize_text_field( wp_unslash( $_GET['improveab_variant'] ) ) : ''; if ( '' === $slug || '' === $variant ) { return null; } return array( 'slug' => $slug, 'variant' => $variant, ); } /** * The current user's own admin preferences on this site: whether they have * seen the intro tour, the New test walkthrough and "How to read these * numbers", and which results view (cards or table) they chose. Stored as * user options, so they follow the person across browsers and stay per site * on a network. * * @return array{tour: bool, walk: bool, howto: bool, view: string} */ function improveab_user_prefs() { $user = get_current_user_id(); $seen = function ( $key ) use ( $user ) { return $user && (bool) get_user_option( 'improveab_' . $key . '_seen', $user ); }; $view = $user ? get_user_option( 'improveab_results_view', $user ) : ''; return array( 'tour' => $seen( 'tour' ), 'walk' => $seen( 'walk' ), 'howto' => $seen( 'howto' ), 'view' => 'table' === $view ? 'table' : 'cards', ); } /** * Maximum number of variants per test: A/B/n with up to six. * A site may lower it through the filter (never below two), and whatever * the filter returns is held to the hard cap the schema and runtime were * designed for: variant keys only go up to v5 and the results page lays out * at most six columns. */ function improveab_max_variants() { $max = (int) apply_filters( 'improveab_max_variants', ImproveAB_Tests::MAX_VARIANTS ); $max = $max > 1 ? $max : 2; return min( $max, ImproveAB_Tests::MAX_VARIANTS ); } /** * Maximum number of tests that may run at once. There is no limit by * default (0 = unlimited); a site may set a ceiling through the filter. */ function improveab_max_active_tests() { return max( 0, (int) apply_filters( 'improveab_max_active_tests', 0 ) ); } /** Goal types offered: every type the runtime tracks; a filter can narrow the list. */ function improveab_goal_types() { $types = (array) apply_filters( 'improveab_goal_types', ImproveAB_Tests::GOAL_TYPES ); return array_values( array_intersect( ImproveAB_Tests::GOAL_TYPES, $types ) ); } /** * Boot the plugin. */ function improveab_init() { ImproveAB_Install::maybe_upgrade(); $rest = new ImproveAB_REST(); $rest->hooks(); if ( is_admin() ) { $admin = new ImproveAB_Admin(); $admin->hooks(); } else { $render = new ImproveAB_Render(); $render->hooks(); } } /** * Declare WooCommerce feature compatibility. The purchase goal only reads * order totals through wc_get_order(), which is HPOS-safe, so declare * compatibility with custom order tables to avoid the incompatibility notice. * The Cart and Checkout blocks too: nothing here touches the checkout form, * and a purchase is credited from the order-received page either checkout * leads to. * WooCommerce assumes that one compatible today and has said the default * will change, so it is stated rather than left to the default. */ function improveab_declare_wc_compat() { if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', IMPROVEAB_FILE, true ); \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', IMPROVEAB_FILE, true ); } }