IDs. * * @var array|null */ private static $owned_cache = null; /** * Whether the last detect_theme_files() hit THEME_FILE_CAP on any file. * * @var bool */ private static $theme_truncated = false; /** * Register hooks. Idempotent. * * @return void */ public static function init() { if ( self::$booted ) { return; } self::$booted = true; add_filter( 'advisorppc_should_load_scripts', array( __CLASS__, 'suppress_on_scan' ), 99 ); add_action( 'send_headers', array( __CLASS__, 'scan_headers' ) ); add_filter( 'site_status_tests', array( __CLASS__, 'register_site_health' ) ); } /* * Module switch. */ /** * Is Auto setup switched on? Free, no account; on unless switched off. * * @return bool */ public static function is_enabled() { if ( ! class_exists( 'AdvisorPPC_Options' ) ) { return false; } $modules = AdvisorPPC_Options::get( 'modules', array() ); if ( is_array( $modules ) && ! array_key_exists( 'detect', $modules ) ) { return true; } return (bool) AdvisorPPC_Options::module_enabled( 'detect' ); } /* * Loopback scan request handling. */ /** * Mint the one-time marker for a scan about to start. * * @return string */ public static function start_scan_token() { $token = wp_generate_password( 32, false, false ); set_transient( self::TOKEN_TRANSIENT, $token, self::TOKEN_TTL ); return $token; } /** * Whether the current request is our own loopback scan. * * Requires the header to carry the unexpired, unguessable marker minted * by start_scan_token(); a bare or guessed header does nothing. * * @return bool */ public static function is_scan_request() { $key = 'HTTP_' . strtoupper( str_replace( '-', '_', self::SCAN_HEADER ) ); if ( empty( $_SERVER[ $key ] ) ) { return false; } $sent = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); if ( 32 !== strlen( $sent ) ) { return false; } $token = get_transient( self::TOKEN_TRANSIENT ); return is_string( $token ) && 32 === strlen( $token ) && hash_equals( $token, $sent ); } /** * Filter advisorppc_should_load_scripts: print none of our tags on the scan. * * @param bool $load Whether to load. * @return bool */ public static function suppress_on_scan( $load ) { return self::is_scan_request() ? false : $load; } /** * On the scan response: never cacheable, and acknowledged, so the scan * can tell our own render from a cached copy. * * @return void */ public static function scan_headers() { if ( ! self::is_scan_request() ) { return; } if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the shared constant page-cache plugins read; prefixing it would defeat it. } if ( ! headers_sent() ) { nocache_headers(); header( self::ACK_HEADER . ': 1' ); } } /* * Source 1: other plugins' settings. */ /** * Registry of the exact option fields read from other plugins. * * Each entry is pinned to the upstream release it was verified against, * cites the file and line that defines the option name and field, and * has a fixture in tests/fixtures/detect-options/{key}.json. Only the * named field is read; a whole option array is never kept or shown, * because some of these rows also hold credentials. * * `plugin_file` is the main file name, matched against active plugins * whatever folder the plugin was installed in. `output_off` names a * verified switch in the same plugin that turns its own tag output off. * * MonsterInsights and PixelYourSite are not listed: their keys could not * be verified against their source, so the page scan covers them. * * @return array */ public static function option_sources() { return array( // Site Kit by Google 1.189.0 (google/site-kit-wp d7084fc): // includes/Modules/Analytics_4/Settings.php:33 OPTION, measurementID at :104, // useSnippet default true at :106. array( 'key' => 'site-kit-ga4', 'plugin' => 'Site Kit by Google', 'plugin_file' => 'google-site-kit.php', 'pinned' => 'google/site-kit-wp 1.189.0 d7084fc', 'option' => 'googlesitekit_analytics-4_settings', 'path' => array( 'measurementID' ), 'platform' => 'ga4', 'output_off' => array( 'path' => array( 'useSnippet' ), 'value' => false, ), ), // Site Kit by Google 1.189.0 (google/site-kit-wp d7084fc): // includes/Modules/Ads/Settings.php:27 OPTION, conversionID at :51. array( 'key' => 'site-kit-ads', 'plugin' => 'Site Kit by Google', 'plugin_file' => 'google-site-kit.php', 'pinned' => 'google/site-kit-wp 1.189.0 d7084fc', 'option' => 'googlesitekit_ads_settings', 'path' => array( 'conversionID' ), 'platform' => 'google_ads', ), // Site Kit by Google 1.189.0 (google/site-kit-wp d7084fc): // includes/Modules/Tag_Manager/Settings.php:31 OPTION, containerID at :82, // useSnippet default true at :85. array( 'key' => 'site-kit-gtm', 'plugin' => 'Site Kit by Google', 'plugin_file' => 'google-site-kit.php', 'pinned' => 'google/site-kit-wp 1.189.0 d7084fc', 'option' => 'googlesitekit_tagmanager_settings', 'path' => array( 'containerID' ), 'platform' => 'gtm', 'output_off' => array( 'path' => array( 'useSnippet' ), 'value' => false, ), ), // GTM4WP 1.22.5 (duracelltomi/gtm4wp d3cac70): common/readoptions.php:11-13 // GTM4WP_OPTIONS 'gtm4wp-options', GTM4WP_OPTION_GTM_CODE 'gtm-code', // GTM4WP_OPTION_GTM_PLACEMENT 'gtm-code-placement', GTM4WP_PLACEMENT_OFF 3 at :114. // 2.0.6 (b005451) keeps all four in compat/constants.php:22,25,32,201. // gtm-code is a comma-separated list of container IDs. array( 'key' => 'gtm4wp', 'plugin' => 'GTM4WP', 'plugin_file' => 'duracelltomi-google-tag-manager-for-wordpress.php', 'pinned' => 'duracelltomi/gtm4wp 1.22.5 d3cac70; 2.0.6 b005451', 'option' => 'gtm4wp-options', 'path' => array( 'gtm-code' ), 'platform' => 'gtm', 'split' => ',', 'output_off' => array( 'path' => array( 'gtm-code-placement' ), 'value' => 3, ), ), // Google Analytics for WooCommerce 2.4.3 (woocommerce/woocommerce-google-analytics-integration // c617222): includes/class-wc-google-analytics.php:32 integration id 'google_analytics', // field 'ga_id' at :122; stored as woocommerce_google_analytics_settings // (tests/unit-tests/SetupTask.php:31). array( 'key' => 'wc-google-analytics', 'plugin' => 'Google Analytics for WooCommerce', 'plugin_file' => 'woocommerce-google-analytics-integration.php', 'pinned' => 'woocommerce/woocommerce-google-analytics-integration 2.4.3 c617222', 'option' => 'woocommerce_google_analytics_settings', 'path' => array( 'ga_id' ), 'platform' => 'ga4', ), // Meta for WooCommerce 3.7.8 (facebook/facebook-for-woocommerce bb0a682): // facebook-commerce.php:73 SETTING_FACEBOOK_PIXEL_ID 'wc_facebook_pixel_id', a plain option. array( 'key' => 'facebook-for-woocommerce', 'plugin' => 'Meta for WooCommerce', 'plugin_file' => 'facebook-for-woocommerce.php', 'pinned' => 'facebook/facebook-for-woocommerce v3.7.8 bb0a682', 'option' => 'wc_facebook_pixel_id', 'path' => array(), 'platform' => 'meta', ), // Meta for WooCommerce 3.7.8, legacy row (facebook/facebook-for-woocommerce bb0a682): // facebook-commerce-pixel-event.php:22-23 SETTINGS_KEY 'facebook_config', PIXEL_ID_KEY 'pixel_id'. array( 'key' => 'facebook-for-woocommerce-legacy', 'plugin' => 'Meta for WooCommerce', 'plugin_file' => 'facebook-for-woocommerce.php', 'pinned' => 'facebook/facebook-for-woocommerce v3.7.8 bb0a682', 'option' => 'facebook_config', 'path' => array( 'pixel_id' ), 'platform' => 'meta', ), ); } /** * Read one scalar field out of an option, read-only. Never returns an * array, so a whole option row cannot leak into a finding. * * @param string $option Option name. * @param string[] $path Array keys to walk; empty for a scalar option. * @return string Empty when absent or not scalar. */ public static function read_option_value( $option, $path ) { $value = get_option( $option, null ); foreach ( (array) $path as $key ) { if ( ! is_array( $value ) || ! array_key_exists( $key, $value ) ) { return ''; } $value = $value[ $key ]; } if ( is_bool( $value ) ) { return $value ? '1' : '0'; } return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * Whether a plugin is active, by main file name. * * @param string $plugin_file Main file name, for example google-site-kit.php. * @return string 'network', 'active' or 'inactive'. */ public static function plugin_state( $plugin_file ) { $suffix = '/' . $plugin_file; $match = static function ( $basenames ) use ( $suffix, $plugin_file ) { foreach ( (array) $basenames as $basename ) { $basename = (string) $basename; if ( $basename === $plugin_file || substr( $basename, -strlen( $suffix ) ) === $suffix ) { return true; } } return false; }; if ( function_exists( 'is_multisite' ) && is_multisite() ) { $network = get_site_option( 'active_sitewide_plugins', array() ); if ( is_array( $network ) && $match( array_keys( $network ) ) ) { return 'network'; } } return $match( get_option( 'active_plugins', array() ) ) ? 'active' : 'inactive'; } /** * Whether a registered source has its own tag output switched off. * * @param array $src option_sources() entry. * @return bool */ private static function output_switched_off( $src ) { if ( empty( $src['output_off'] ) ) { return false; } $raw = self::read_option_value( $src['option'], $src['output_off']['path'] ); if ( '' === $raw ) { return false; } $off = $src['output_off']['value']; if ( is_bool( $off ) ) { return ( $off ? '1' : '0' ) === $raw; } return (string) $off === $raw; } /** * Valid IDs held in one registered source. * * @param array $src option_sources() entry. * @return string[] */ private static function source_ids( $src ) { $value = self::read_option_value( $src['option'], $src['path'] ); if ( '' === $value ) { return array(); } $ids = array(); foreach ( empty( $src['split'] ) ? array( $value ) : explode( $src['split'], $value ) as $one ) { $id = self::normalise_id( $src['platform'], $one ); if ( '' !== $id ) { $ids[] = $id; } } return array_values( array_unique( $ids ) ); } /** * IDs stored in other plugins' settings, with each plugin's state. * * @return array */ public static function detect_options() { $found = array(); $names = array( 'network' => __( 'network active', 'advisorppc' ), 'active' => __( 'active', 'advisorppc' ), 'inactive' => __( 'inactive', 'advisorppc' ), ); foreach ( self::option_sources() as $src ) { $ids = self::source_ids( $src ); if ( empty( $ids ) ) { continue; } $state = self::plugin_state( $src['plugin_file'] ); $off = self::output_switched_off( $src ); foreach ( $ids as $id ) { $found[] = array( 'platform' => $src['platform'], 'id' => $id, 'source' => 'option', 'source_key' => $src['key'], 'plugin_state' => $state, 'output_off' => $off, /* translators: 1: plugin name, 2: active, inactive or network active. */ 'label' => sprintf( __( '%1$s (%2$s)', 'advisorppc' ), $src['plugin'], $names[ $state ] ), 'evidence' => $src['option'] . ( empty( $src['path'] ) ? '' : '[' . implode( '][', $src['path'] ) . ']' ) . ( $off ? ' ' . __( '(its own tag output is switched off)', 'advisorppc' ) : '' ), ); } } return $found; } /** * Network => ID pairs that a registered, active plugin is loading. * * Active (or network active) and not switched off counts as loading: * when in doubt we assume it loads, because a missing tag is visible and * a doubled one silently counts everything twice. * * @return array */ public static function externally_owned() { if ( null !== self::$owned_cache ) { return self::$owned_cache; } $owned = array(); foreach ( self::option_sources() as $src ) { if ( 'inactive' === self::plugin_state( $src['plugin_file'] ) || self::output_switched_off( $src ) ) { continue; } foreach ( self::source_ids( $src ) as $id ) { $owned[ $src['platform'] ][] = $id; } } self::$owned_cache = $owned; return $owned; } /** * Clear the per-request ownership cache. * * @return void */ public static function flush_owned_cache() { self::$owned_cache = null; } /** * Whether another plugin is loading this exact ID. * * @param string $network Network key. * @param string $id ID. * @return bool */ public static function is_externally_owned( $network, $id ) { $owned = self::externally_owned(); if ( ! isset( $owned[ $network ] ) ) { return false; } // Owned IDs went through normalise_id(); the saved one may not have // (the tag rules accept g-abcd1234), so compare canonical forms. $canonical = self::normalise_id( $network, (string) $id ); if ( '' === $canonical ) { $canonical = trim( (string) $id ); } return in_array( $canonical, $owned[ $network ], true ); } /** * Called by AdvisorPPC_Tags::tag_ids(): drop every ID another plugin is * already loading, so we add no library, init, page_view or noscript for * it. A conversion label goes with its conversion ID. * * @param array $ids Validated tag IDs. * @return array */ public static function drop_externally_owned( $ids ) { if ( ! is_array( $ids ) ) { return array(); } foreach ( $ids as $network => $id ) { if ( 'google_ads_label' !== $network && self::is_externally_owned( $network, $id ) ) { unset( $ids[ $network ] ); if ( 'google_ads' === $network ) { unset( $ids['google_ads_label'] ); } } } return $ids; } /** * What the plan depends on outside our own settings: each registered * source's IDs, plugin state and output switch, and the active theme. * * @return string sha1 hex. */ public static function sources_fingerprint() { $parts = array( self::REGISTRY_VERSION ); foreach ( self::option_sources() as $src ) { $parts[] = array( $src['key'], self::source_ids( $src ), self::plugin_state( $src['plugin_file'] ), self::output_switched_off( $src ) ); } $parts[] = function_exists( 'get_stylesheet' ) ? get_stylesheet() : ''; $parts[] = function_exists( 'get_template' ) ? get_template() : ''; return sha1( (string) wp_json_encode( $parts ) ); } /* * Source 2: theme files. */ /** * IDs hard-coded in the active theme (child and parent). * * @param string|null $child Child theme directory; null for the active one. * @param string|null $parent_dir Parent theme directory; null for the active one. * @return array */ public static function detect_theme_files( $child = null, $parent_dir = null ) { self::$theme_truncated = false; if ( null === $child ) { $child = function_exists( 'get_stylesheet_directory' ) ? get_stylesheet_directory() : ''; } if ( null === $parent_dir ) { $parent_dir = function_exists( 'get_template_directory' ) ? get_template_directory() : ''; } $dirs = array(); if ( '' !== (string) $child ) { $dirs[ (string) $child ] = ( (string) $child !== (string) $parent_dir ) ? 'child' : 'theme'; } if ( '' !== (string) $parent_dir && ! isset( $dirs[ (string) $parent_dir ] ) ) { $dirs[ (string) $parent_dir ] = 'theme'; } $found = array(); foreach ( $dirs as $dir => $kind ) { foreach ( self::THEME_FILES as $file ) { $path = trailingslashit( $dir ) . $file; if ( ! is_file( $path ) || ! is_readable( $path ) ) { continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local theme file, read-only, size-capped. $text = file_get_contents( $path, false, null, 0, self::THEME_FILE_CAP ); // A file longer than the cap was not read in full: an ID past // the cap is unseen, so the scan cannot rule ownership out. if ( (int) filesize( $path ) > self::THEME_FILE_CAP ) { self::$theme_truncated = true; } if ( ! is_string( $text ) || '' === $text ) { continue; } /* translators: %s: theme file name, for example header.php. */ $label = 'child' === $kind ? sprintf( __( 'Child theme %s', 'advisorppc' ), $file ) : sprintf( __( 'Theme %s', 'advisorppc' ), $file ); foreach ( self::extract_ids( $text ) as $hit ) { $hit['source'] = 'theme'; $hit['label'] = $label; $found[] = $hit; } } } return $found; } /* * Source 3: the home page. */ /** * The scheme://host[:port] of a URL, lower-cased, default ports dropped. * * @param string $url URL. * @return string Empty when unparseable. */ public static function origin_of( $url ) { $p = wp_parse_url( (string) $url ); if ( ! is_array( $p ) || empty( $p['scheme'] ) || empty( $p['host'] ) ) { return ''; } $scheme = strtolower( $p['scheme'] ); $port = isset( $p['port'] ) ? (int) $p['port'] : 0; if ( ( 'https' === $scheme && 443 === $port ) || ( 'http' === $scheme && 80 === $port ) ) { $port = 0; } return $scheme . '://' . strtolower( $p['host'] ) . ( $port ? ':' . $port : '' ); } /** * Fetch the home page and extract the IDs other sources print. * * The contract: wp_safe_remote_get() to the configured home origin only; * no cookies or authorization forwarded; short timeout; response capped; * redirects not followed by the transport, and followed here only when * they stay on the home origin; a one-time marker in the header so the * site renders without our tags; cache-busting query and no-cache request * headers. If the response does not carry our acknowledgement it came * from a cache, and the result is partial. A failed or partial fetch is * never read as "not present". * * @return array{ok:bool,error:string,ids:array,loaders:array} */ public static function scan_html() { $fail = static function ( $error ) { return array( 'ok' => false, 'error' => $error, 'ids' => array(), 'loaders' => array(), ); }; $home = home_url( '/' ); $origin = self::origin_of( $home ); if ( '' === $origin ) { return $fail( 'bad_home' ); } $token = self::start_scan_token(); $url = add_query_arg( 'advisorppc_scan', substr( sha1( $token ), 0, 12 ), $home ); $args = array( 'timeout' => 5, 'redirection' => 0, 'limit_response_size' => self::HTML_CAP, 'cookies' => array(), 'headers' => array( self::SCAN_HEADER => $token, 'Cache-Control' => 'no-cache', 'Pragma' => 'no-cache', ), /** This filter is documented in wp-includes/class-wp-http-streams.php */ 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own loopback filter, applied the way core applies it. ); $response = null; for ( $hop = 0; $hop <= self::MAX_REDIRECTS; $hop++ ) { $response = wp_safe_remote_get( $url, $args ); if ( is_wp_error( $response ) ) { delete_transient( self::TOKEN_TRANSIENT ); return $fail( 'request_failed' ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 300 || $code >= 400 ) { break; } $location = (string) wp_remote_retrieve_header( $response, 'location' ); if ( '' !== $location && 0 === strpos( $location, '/' ) && 0 !== strpos( $location, '//' ) ) { $location = $origin . $location; } if ( '' === $location || self::origin_of( $location ) !== $origin ) { delete_transient( self::TOKEN_TRANSIENT ); return $fail( 'external_redirect' ); } if ( self::MAX_REDIRECTS === $hop ) { delete_transient( self::TOKEN_TRANSIENT ); return $fail( 'too_many_redirects' ); } $url = $location; } delete_transient( self::TOKEN_TRANSIENT ); $code = (int) wp_remote_retrieve_response_code( $response ); $body = (string) wp_remote_retrieve_body( $response ); if ( $code < 200 || $code >= 300 || '' === $body ) { return $fail( 'bad_status' ); } if ( '1' !== trim( (string) wp_remote_retrieve_header( $response, strtolower( self::ACK_HEADER ) ) ) ) { // Served from a cache: our own tags may be in it and anything // else may be out of date. Nothing from it counts. return $fail( 'cached' ); } if ( strlen( $body ) >= self::HTML_CAP ) { // Cut off at the response cap: what is missing could hold any // tag, so nothing from a partial page counts. return $fail( 'truncated' ); } $ids = array(); foreach ( self::extract_ids( $body ) as $hit ) { $hit['source'] = 'html'; $hit['label'] = __( 'Fetched home page', 'advisorppc' ); $ids[] = $hit; } return array( 'ok' => true, 'error' => '', 'ids' => $ids, 'loaders' => self::count_loaders( $body ), ); } /** * Context-anchored patterns, one list per platform. Each pattern has the * ID in group 1. A bare "G-XXXX" string with no tag context never counts. * * @return array */ public static function patterns() { $q = '[\'"]'; return array( 'ga4' => array( '#googletagmanager\.com/gtag/js\?id=(G-[A-Z0-9]{4,20})\b#i', '#gtag\(\s*' . $q . 'config' . $q . '\s*,\s*' . $q . '(G-[A-Z0-9]{4,20})' . $q . '#i', ), 'google_ads' => array( '#googletagmanager\.com/gtag/js\?id=(AW-[0-9]{6,15})\b#i', '#gtag\(\s*' . $q . 'config' . $q . '\s*,\s*' . $q . '(AW-[0-9]{6,15})' . $q . '#i', '#' . $q . 'send_to' . $q . '\s*:\s*' . $q . '(AW-[0-9]{6,15})/#i', ), 'gtm' => array( '#googletagmanager\.com/gtm\.js\?id=(GTM-[A-Z0-9]{4,12})\b#i', '#googletagmanager\.com/ns\.html\?id=(GTM-[A-Z0-9]{4,12})\b#i', '#\(\s*window\s*,\s*document\s*,\s*' . $q . 'script' . $q . '\s*,\s*' . $q . '[A-Za-z_$][A-Za-z0-9_$]*' . $q . '\s*,\s*' . $q . '(GTM-[A-Z0-9]{4,12})' . $q . '\s*\)#i', ), 'meta' => array( '#fbq\(\s*' . $q . 'init' . $q . '\s*,\s*' . $q . '([0-9]{8,20})' . $q . '#', '#facebook\.com/tr\?id=([0-9]{8,20})\b#', ), 'tiktok' => array( '#ttq\.load\(\s*' . $q . '([A-Z0-9]{10,32})' . $q . '#i', '#analytics\.tiktok\.com/i18n/pixel/events\.js\?sdkid=([A-Z0-9]{10,32})\b#i', ), 'uet' => array( '#bat\.bing\.com/action/0\?ti=([0-9]{5,15})\b#', '#new\s+UET\(\s*\{[^}]{0,200}?\bti\s*:\s*' . $q . '?([0-9]{5,15})#', ), ); } /** * Extract every context-anchored ID from a block of text. * * Also picks up a Google Ads conversion label from `send_to: 'AW-1/label'` * as platform google_ads_label, with the conversion ID alongside. * * @param string $text HTML or PHP source. * @return array */ public static function extract_ids( $text ) { $text = (string) $text; $out = array(); $seen = array(); // The UET object form only counts on a page that loads bat.js. $has_uet = false !== stripos( $text, 'bat.bing.com/bat.js' ); foreach ( self::patterns() as $platform => $list ) { foreach ( $list as $i => $pattern ) { if ( 'uet' === $platform && 1 === $i && ! $has_uet ) { continue; } if ( ! preg_match_all( $pattern, $text, $m, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { continue; } foreach ( $m as $match ) { $id = self::normalise_id( $platform, $match[1][0] ); if ( '' === $id || isset( $seen[ $platform . '|' . $id ] ) ) { continue; } $seen[ $platform . '|' . $id ] = true; $out[] = array( 'platform' => $platform, 'id' => $id, 'evidence' => self::snippet( $match[0][0] ), ); } } } $label_re = '#[\'"]send_to[\'"]\s*:\s*[\'"](AW-[0-9]{6,15})/([A-Za-z0-9_\-]{6,40})[\'"]#i'; if ( preg_match_all( $label_re, $text, $m, PREG_SET_ORDER ) ) { foreach ( $m as $match ) { $conv = self::normalise_id( 'google_ads', $match[1] ); $label = self::normalise_id( 'google_ads_label', $match[2] ); if ( '' === $conv || '' === $label || isset( $seen[ 'label|' . $conv . '/' . $label ] ) ) { continue; } $seen[ 'label|' . $conv . '/' . $label ] = true; $out[] = array( 'platform' => 'google_ads_label', 'id' => $label, 'conversion_id' => $conv, 'evidence' => self::snippet( $match[0] ), ); } } return $out; } /** * Count each vendor loader on a page; more than one of a kind loads that * library twice. * * @param string $html Page HTML. * @return array */ public static function count_loaders( $html ) { $html = (string) $html; $patterns = array( 'gtag' => '#]*\bsrc=["\'][^"\']*googletagmanager\.com/gtag/js\?id=#i', 'gtm' => '#googletagmanager\.com/gtm\.js\?id=#i', 'meta' => '#connect\.facebook\.net/[a-z_A-Z]+/fbevents\.js#', 'tiktok' => '#analytics\.tiktok\.com/i18n/pixel/events\.js#i', 'uet' => '#bat\.bing\.com/bat\.js#i', ); $counts = array(); foreach ( $patterns as $key => $re ) { $n = preg_match_all( $re, $html ); if ( $n ) { $counts[ $key ] = (int) $n; } } return $counts; } /** * Canonical form of an ID, or '' when it fails the tag module's rules. * * @param string $platform Network key. * @param string $value Candidate. * @return string */ public static function normalise_id( $platform, $value ) { $value = trim( (string) $value ); if ( in_array( $platform, array( 'ga4', 'google_ads', 'gtm', 'tiktok' ), true ) ) { $value = strtoupper( $value ); } if ( ! class_exists( 'AdvisorPPC_Tags' ) || ! AdvisorPPC_Tags::validate_tag_id( $platform, $value ) ) { return ''; } return $value; } /** * A short plain-text excerpt for the Evidence column. * * @param string $text Matched text. * @return string */ private static function snippet( $text ) { $text = preg_replace( '/\s+/', ' ', wp_strip_all_tags( (string) $text ) ); $text = trim( (string) $text ); return strlen( $text ) > 90 ? substr( $text, 0, 87 ) . '...' : $text; } /* * Merge and plan. */ /** * Fold raw hits from every source into one row per platform and ID. * * @param array $hits Hits from detect_options(), detect_theme_files(), scan_html()['ids']. * @return array */ public static function merge_findings( $hits ) { $rows = array(); foreach ( (array) $hits as $hit ) { if ( ! is_array( $hit ) || empty( $hit['platform'] ) || empty( $hit['id'] ) ) { continue; } $conv = isset( $hit['conversion_id'] ) ? (string) $hit['conversion_id'] : ''; $key = $hit['platform'] . '|' . $conv . '|' . $hit['id']; if ( ! isset( $rows[ $key ] ) ) { $rows[ $key ] = array( 'platform' => (string) $hit['platform'], 'id' => (string) $hit['id'], 'conversion_id' => $conv, 'found_in' => array(), 'live' => false, 'evidence' => array(), 'owners' => array(), 'in_theme' => false, ); } $label = isset( $hit['label'] ) ? (string) $hit['label'] : ''; if ( '' !== $label && ! in_array( $label, $rows[ $key ]['found_in'], true ) ) { $rows[ $key ]['found_in'][] = $label; } $source = isset( $hit['source'] ) ? $hit['source'] : ''; if ( 'html' === $source ) { $rows[ $key ]['live'] = true; } elseif ( 'theme' === $source ) { $rows[ $key ]['in_theme'] = true; } elseif ( 'option' === $source ) { $rows[ $key ]['owners'][] = array( 'plugin' => $label, 'state' => isset( $hit['plugin_state'] ) ? (string) $hit['plugin_state'] : 'active', 'output_off' => ! empty( $hit['output_off'] ), ); } $evidence = isset( $hit['evidence'] ) ? (string) $hit['evidence'] : ''; if ( '' !== $evidence && ! in_array( $evidence, $rows[ $key ]['evidence'], true ) ) { $rows[ $key ]['evidence'][] = $evidence; } } return array_values( $rows ); } /** * Why a finding cannot be applied, or '' when ownership is known absent. * * Apply is offered only for an ID that a registered plugin holds while * it is inactive or has its own output off, that the theme does not * contain, that a clean fetch did not find, and that no live Tag Manager * container could be firing. Everything else is inspection only. * * @param array $f merge_findings() row. * @param string $network Network key. * @param bool $scan_ok Clean fetch. * @param bool $gtm_live A Tag Manager container was in the fetched HTML. * @return string Plain-language reason with manual guidance. */ private static function blocker( $f, $network, $scan_ok, $gtm_live ) { if ( ! $scan_ok ) { return __( 'Your home page could not be fetched cleanly, so it is not known whether this tag already loads. Nothing is applied on a guess. Fix the fetch and scan again.', 'advisorppc' ); } if ( ! empty( $f['live'] ) ) { return __( 'This tag already loads on your site from another source. Keep it there: adding it here would load it twice. To move it to AdvisorPPC, remove it from the other source first, then scan again.', 'advisorppc' ); } foreach ( (array) $f['owners'] as $o ) { if ( 'inactive' !== $o['state'] && empty( $o['output_off'] ) ) { /* translators: %s: plugin name and state, for example "Site Kit by Google (active)". */ return sprintf( __( '%s is active and may load this tag on pages the scan did not fetch. To move it to AdvisorPPC, switch off that plugin\'s tag output, then scan again.', 'advisorppc' ), $o['plugin'] ); } } if ( ! empty( $f['in_theme'] ) ) { return __( 'Your theme contains this tag and may print it on pages the scan did not fetch. To move it to AdvisorPPC, remove it from the theme, then scan again.', 'advisorppc' ); } if ( empty( $f['owners'] ) ) { return __( 'Where this tag comes from could not be confirmed. Check it by hand before adding it here.', 'advisorppc' ); } if ( $gtm_live && 'gtm' !== $network ) { return __( 'A Tag Manager container loads on your home page and may already fire this tag. Check the container before adding it here.', 'advisorppc' ); } return ''; } /** * Turn findings into proposed changes. Pure. * * @param array $findings merge_findings() rows. * @param array $environment workspace (bool, ADVISORPPC_WORKSPACE), connected (bool), * modules_available (module keys this build ships), * tracktor_choice ('', 'on' or 'off') and scan_ok * (bool, the home page fetch was clean). * @param array $settings The tag_ids, consent_mode and modules settings. * @return array */ public static function plan( $findings, $environment, $settings ) { $env = array_merge( array( 'workspace' => false, 'connected' => false, 'modules_available' => array(), 'tracktor_choice' => '', 'scan_ok' => false, ), is_array( $environment ) ? $environment : array() ); $tag_ids = isset( $settings['tag_ids'] ) && is_array( $settings['tag_ids'] ) ? $settings['tag_ids'] : array(); $modules = isset( $settings['modules'] ) && is_array( $settings['modules'] ) ? $settings['modules'] : array(); $available = (array) $env['modules_available']; $findings = is_array( $findings ) ? $findings : array(); $scan_ok = (bool) $env['scan_ok']; $gtm_live = false; foreach ( $findings as $f ) { if ( 'gtm' === $f['platform'] && ! empty( $f['live'] ) ) { $gtm_live = true; } } $rows = array(); $aw_rows = array(); foreach ( self::NETWORKS as $network ) { $candidates = array(); foreach ( $findings as $f ) { if ( $network === $f['platform'] && class_exists( 'AdvisorPPC_Tags' ) && AdvisorPPC_Tags::validate_tag_id( $network, $f['id'] ) ) { $candidates[] = $f; } } if ( empty( $candidates ) ) { continue; } $current = isset( $tag_ids[ $network ] ) ? trim( (string) $tag_ids[ $network ] ) : ''; if ( '' !== $current && in_array( $current, wp_list_pluck( $candidates, 'id' ), true ) ) { continue; } $verdicts = array(); $applicable = 0; foreach ( $candidates as $i => $f ) { $verdicts[ $i ] = self::blocker( $f, $network, $scan_ok, $gtm_live ); if ( '' === $verdicts[ $i ] ) { ++$applicable; } } foreach ( $candidates as $i => $f ) { $params = array( 'network' => $network, 'id' => $f['id'], ); if ( '' !== $verdicts[ $i ] ) { $row = self::row( 'inspect', $params, false, false, false, $f['evidence'], $verdicts[ $i ] ); } elseif ( '' !== $current ) { /* translators: %s: the ID currently saved in this plugin. */ $row = self::row( 'set_tag_id', $params, false, true, false, $f['evidence'], sprintf( __( 'This plugin already has a different ID saved (%s). Replacing it is your decision.', 'advisorppc' ), $current ) ); } elseif ( $applicable > 1 ) { $row = self::row( 'set_tag_id', $params, false, true, false, $f['evidence'], __( 'Several different IDs were found for this platform. Choose the one you use.', 'advisorppc' ) ); } else { /* translators: %s: where the ID was found. */ $row = self::row( 'set_tag_id', $params, true, true, false, $f['evidence'], sprintf( __( 'Found in %s, which is not loading it, and not in your home page. Adding it here loads it once, from this plugin.', 'advisorppc' ), implode( ', ', $f['found_in'] ) ) ); } $rows[] = $row; if ( 'google_ads' === $network ) { $aw_rows[ $f['id'] ] = $row; } } } // Google Ads conversion label: only alongside a matching conversion ID. $current_label = isset( $tag_ids['google_ads_label'] ) ? trim( (string) $tag_ids['google_ads_label'] ) : ''; $current_aw = isset( $tag_ids['google_ads'] ) ? trim( (string) $tag_ids['google_ads'] ) : ''; if ( '' === $current_label ) { foreach ( $findings as $f ) { if ( 'google_ads_label' !== $f['platform'] || ! class_exists( 'AdvisorPPC_Tags' ) || ! AdvisorPPC_Tags::validate_tag_id( 'google_ads_label', $f['id'] ) ) { continue; } $conv = $f['conversion_id']; $params = array( 'network' => 'google_ads_label', 'id' => $f['id'], ); if ( ! isset( $aw_rows[ $conv ] ) && ( '' === $conv || $conv !== $current_aw ) ) { continue; } if ( isset( $aw_rows[ $conv ] ) && $aw_rows[ $conv ]['applicable'] && empty( $f['live'] ) ) { $rows[] = self::row( 'set_tag_id', $params, $aw_rows[ $conv ]['recommended'], true, false, $f['evidence'], __( 'The conversion label that goes with the Google Ads ID above. A conversion cannot fire without it.', 'advisorppc' ) ); } else { $rows[] = self::row( 'inspect', $params, false, false, false, $f['evidence'], __( 'This conversion label is used by a tag that already loads or whose source is not confirmed. Add it here only together with its conversion ID, once that tag no longer loads elsewhere.', 'advisorppc' ) ); } } } $any_applicable = false; $any_recommended = false; foreach ( $rows as $r ) { $any_applicable = $any_applicable || $r['applicable']; $any_recommended = $any_recommended || $r['recommended']; } // Tracking tags must be on for any of the IDs above to load. if ( $any_applicable && in_array( 'tags', $available, true ) && empty( $modules['tags'] ) ) { $rows[] = self::row( 'enable_module', array( 'module' => 'tags' ), $any_recommended, true, false, array(), __( 'IDs saved here load only while Tracking tags is switched on.', 'advisorppc' ) ); } // TrackTor: only in a workspace build, only once connected, never // against a human "off", and never pre-ticked. if ( $env['workspace'] && $env['connected'] && in_array( 'tracktor', $available, true ) && empty( $modules['tracktor'] ) && 'off' !== $env['tracktor_choice'] ) { $rows[] = self::row( 'enable_module', array( 'module' => 'tracktor' ), false, true, true, array(), __( 'Optional. Loads the TrackTor tag so visits and conversions appear in your AdvisorPPC workspace.', 'advisorppc' ) ); } return $rows; } /** * Build one plan row with a stable key. * * @param string $action set_tag_id, enable_module or inspect. * @param array $params Action parameters. * @param bool $recommended Pre-ticked. * @param bool $applicable Can be applied at all. * @param bool $needs_workspace Needs a connected workspace. * @param array $evidence Evidence snippets. * @param string $why Plain-language reason. * @return array */ private static function row( $action, $params, $recommended, $applicable, $needs_workspace, $evidence, $why ) { return array( 'key' => substr( sha1( $action . '|' . implode( '|', $params ) ), 0, 12 ), 'action' => $action, 'params' => $params, 'recommended' => (bool) $recommended && (bool) $applicable, 'applicable' => (bool) $applicable, 'needs_workspace' => (bool) $needs_workspace, 'evidence' => array_values( (array) $evidence ), 'why' => (string) $why, ); } /* * State. */ /** * The settings this plan depends on, hashed. * * @param array|null $settings Settings array; null reads the stored one. * @return string sha1 hex. */ public static function settings_hash( $settings = null ) { if ( null === $settings ) { $settings = AdvisorPPC_Options::all(); } $tag_ids = isset( $settings['tag_ids'] ) && is_array( $settings['tag_ids'] ) ? $settings['tag_ids'] : array(); $modules = isset( $settings['modules'] ) && is_array( $settings['modules'] ) ? $settings['modules'] : array(); ksort( $tag_ids ); ksort( $modules ); $mode = isset( $settings['consent_mode'] ) ? (string) $settings['consent_mode'] : ''; return sha1( (string) wp_json_encode( array( $tag_ids, $mode, $modules ) ) ); } /** * The current environment for plan(). * * @param bool $scan_ok Whether the home page fetch was clean. * @return array */ public static function environment( $scan_ok ) { $available = array( 'tags' ); if ( AdvisorPPC_Options::workspace_enabled() && class_exists( 'AdvisorPPC_Tracktor' ) ) { $available[] = 'tracktor'; } return array( 'workspace' => AdvisorPPC_Options::workspace_enabled(), 'connected' => AdvisorPPC_Options::is_connected(), 'modules_available' => $available, 'tracktor_choice' => class_exists( 'AdvisorPPC_Tracktor' ) ? AdvisorPPC_Tracktor::user_choice() : '', 'scan_ok' => (bool) $scan_ok, ); } /** * Drop cached copies so the next read comes from the database. Used * under the lock, where another administrator may have just written. * * @return void */ private static function fresh_reads() { if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); wp_cache_delete( self::STATE_OPTION, 'options' ); wp_cache_delete( self::RECEIPTS_OPTION, 'options' ); } AdvisorPPC_Options::flush_cache(); self::flush_owned_cache(); } /** * Read a non-autoloaded array option. * * @param string $name Option name. * @return array */ private static function read_array( $name ) { $value = get_option( $name, array() ); return is_array( $value ) ? $value : array(); } /** * Write a non-autoloaded option. * * @param string $name Option name. * @param mixed $value Value. * @return void */ private static function write_array( $name, $value ) { if ( false === get_option( $name, false ) ) { add_option( $name, $value, '', false ); return; } update_option( $name, $value, false ); } /** * Take the short apply/undo lock. Atomic: one INSERT IGNORE, so two * administrators clicking at once cannot both hold it. * * @return bool */ private static function acquire_lock() { global $wpdb; $now = time(); for ( $try = 0; $try < 2; $try++ ) { if ( isset( $wpdb ) && is_object( $wpdb ) && isset( $wpdb->options ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic insert is the lock; the options API has no compare-and-set. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", self::LOCK_OPTION, (string) $now ) ); if ( 1 === (int) $inserted ) { return true; } } elseif ( add_option( self::LOCK_OPTION, (string) $now, '', false ) ) { return true; } if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( self::LOCK_OPTION, 'options' ); wp_cache_delete( 'notoptions', 'options' ); } $held = (int) get_option( self::LOCK_OPTION, 0 ); if ( $held > 0 && ( $now - $held ) <= self::LOCK_TTL ) { return false; } delete_option( self::LOCK_OPTION ); } return false; } /** * Release the lock. * * @return void */ private static function release_lock() { delete_option( self::LOCK_OPTION ); } /** * The stored scan state. * * @return array */ public static function state() { return self::read_array( self::STATE_OPTION ); } /** * Stored receipts. * * @return array */ public static function receipts() { return array_values( self::read_array( self::RECEIPTS_OPTION ) ); } /** * A fresh, unguessable plan revision. * * @return string */ private static function new_rev() { return wp_generate_password( 20, false, false ); } /** * Run all three detectors, plan, and store the result. * * @return array The stored state. */ public static function run_scan() { $options = self::detect_options(); $theme = self::detect_theme_files(); $html = self::scan_html(); if ( self::$theme_truncated && $html['ok'] ) { $html = array( 'ok' => false, 'error' => 'theme_truncated', 'ids' => array(), 'loaders' => array(), ); } $findings = self::merge_findings( array_merge( $options, $theme, $html['ids'] ) ); $settings = AdvisorPPC_Options::all(); $plan = self::plan( $findings, self::environment( $html['ok'] ), $settings ); if ( $html['ok'] ) { $status = 'ok'; } else { $status = empty( $findings ) ? 'error' : 'partial'; } $previous = self::state(); $state = array( 'scanned_at' => time(), 'status' => $status, 'error' => $html['error'], 'findings' => $findings, 'plan' => $plan, 'plan_rev' => self::new_rev(), 'loaders' => $html['loaders'], 'settings_hash' => self::settings_hash( $settings ), 'sources_fp' => self::sources_fingerprint(), 'registry' => self::REGISTRY_VERSION, 'dismissed' => false, 'last_receipt' => isset( $previous['last_receipt'] ) ? (string) $previous['last_receipt'] : '', 'applied_at' => 0, ); self::write_array( self::STATE_OPTION, $state ); return $state; } /** * Hide the findings until the next scan. * * @return void */ public static function dismiss() { $state = self::state(); if ( empty( $state ) ) { return; } $state['dismissed'] = true; $state['plan_rev'] = self::new_rev(); self::write_array( self::STATE_OPTION, $state ); } /** * Which of the six card states applies. * * @param array $state Stored state. * @param string $current_hash settings_hash() now. * @param int $now Unix time. * @param string $current_fp sources_fingerprint() now; '' skips that test. * @return string never_scanned|findings|applied|stale|error|partial */ public static function card_state( $state, $current_hash, $now, $current_fp = '' ) { if ( empty( $state ) || ! empty( $state['dismissed'] ) || empty( $state['scanned_at'] ) ) { return 'never_scanned'; } if ( 'error' === ( isset( $state['status'] ) ? $state['status'] : '' ) ) { return 'error'; } if ( ( isset( $state['settings_hash'] ) ? $state['settings_hash'] : '' ) !== $current_hash || ( '' !== $current_fp && ( isset( $state['sources_fp'] ) ? $state['sources_fp'] : '' ) !== $current_fp ) || ( isset( $state['registry'] ) ? $state['registry'] : '' ) !== self::REGISTRY_VERSION || ( (int) $now - (int) $state['scanned_at'] ) > self::STALE_AFTER ) { return 'stale'; } if ( ! empty( $state['applied_at'] ) ) { return 'applied'; } if ( 'partial' === $state['status'] ) { return 'partial'; } return 'findings'; } /* * Apply and undo. */ /** * Paths apply and undo may touch, and nothing else. * * @param string $path dotted path. * @return bool */ public static function is_allowed_path( $path ) { $parts = explode( '.', (string) $path ); if ( 2 !== count( $parts ) ) { return false; } if ( 'tag_ids' === $parts[0] ) { return in_array( $parts[1], array_merge( self::NETWORKS, array( 'google_ads_label' ) ), true ); } if ( 'modules' === $parts[0] ) { return in_array( $parts[1], self::ENABLEABLE, true ); } return false; } /** * Current value at a dotted path. * * @param string $path tag_ids.x or modules.x. * @return mixed */ private static function path_value( $path ) { list( $group, $key ) = explode( '.', $path ); $map = AdvisorPPC_Options::get( $group, array() ); if ( 'modules' === $group ) { return is_array( $map ) && ! empty( $map[ $key ] ); } return is_array( $map ) && isset( $map[ $key ] ) ? (string) $map[ $key ] : ''; } /** * Write a value at a dotted path, leaving every other key in the group * as stored, then read it back. * * @param string $path tag_ids.x or modules.x. * @param mixed $value String for tag_ids, bool for modules. * @return bool Whether the stored value now equals $value. */ private static function write_path( $path, $value ) { list( $group, $key ) = explode( '.', $path ); $map = AdvisorPPC_Options::get( $group, array() ); $map = is_array( $map ) ? $map : array(); $map[ $key ] = 'modules' === $group ? (bool) $value : (string) $value; AdvisorPPC_Options::set( $group, $map ); self::fresh_reads(); return self::path_value( $path ) === ( 'modules' === $group ? (bool) $value : (string) $value ); } /** * Keep the receipts row within its count and byte caps, oldest first out. * * @param array $all Receipts, oldest first. * @return array */ public static function cap_receipts( $all ) { $all = array_values( (array) $all ); if ( count( $all ) > self::RECEIPTS_CAP ) { $all = array_slice( $all, -self::RECEIPTS_CAP ); } $bytes = strlen( serialize( $all ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- measuring the size the options table will store. $rows = count( $all ); while ( $rows > 1 && $bytes > self::RECEIPTS_MAX_BYTES ) { array_shift( $all ); $bytes = strlen( serialize( $all ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- as above. $rows = count( $all ); } return $all; } /** * Apply the selected rows of the stored plan. * * @param string[] $keys Plan row keys the administrator ticked. * @param int $user_id Who clicked. * @param string $plan_rev Plan revision the form was rendered from. * @return array{status:string,receipt_id:string,changed:int,failed:int} * status: applied | partial | failed | nothing | stale | busy | no_plan. */ public static function apply( $keys, $user_id, $plan_rev ) { $out = array( 'status' => 'no_plan', 'receipt_id' => '', 'changed' => 0, 'failed' => 0, ); if ( ! self::acquire_lock() ) { $out['status'] = 'busy'; return $out; } try { self::fresh_reads(); $state = self::state(); if ( empty( $state['plan'] ) || ! is_array( $state['plan'] ) ) { return $out; } if ( empty( $state['plan_rev'] ) || ! hash_equals( (string) $state['plan_rev'], (string) $plan_rev ) || empty( $state['settings_hash'] ) || ! hash_equals( (string) $state['settings_hash'], self::settings_hash() ) || empty( $state['sources_fp'] ) || ! hash_equals( (string) $state['sources_fp'], self::sources_fingerprint() ) || ( isset( $state['registry'] ) ? $state['registry'] : '' ) !== self::REGISTRY_VERSION ) { $out['status'] = 'stale'; return $out; } $keys = array_map( 'strval', (array) $keys ); $receipt_id = substr( sha1( self::new_rev() ), 0, 16 ); $now = time(); $receipts = array(); $failed = 0; foreach ( $state['plan'] as $row ) { if ( ! in_array( (string) $row['key'], $keys, true ) || empty( $row['applicable'] ) ) { continue; } if ( ! empty( $row['needs_workspace'] ) && ! AdvisorPPC_Options::is_connected() ) { continue; } if ( 'set_tag_id' === $row['action'] ) { $network = isset( $row['params']['network'] ) ? (string) $row['params']['network'] : ''; $id = isset( $row['params']['id'] ) ? (string) $row['params']['id'] : ''; $path = 'tag_ids.' . $network; if ( ! self::is_allowed_path( $path ) || ! AdvisorPPC_Tags::validate_tag_id( $network, $id ) ) { continue; } if ( 'google_ads_label' !== $network && self::is_externally_owned( $network, $id ) ) { continue; } $after = $id; } elseif ( 'enable_module' === $row['action'] ) { $module = isset( $row['params']['module'] ) ? (string) $row['params']['module'] : ''; $path = 'modules.' . $module; if ( ! self::is_allowed_path( $path ) ) { continue; } if ( 'tracktor' === $module && ( ! class_exists( 'AdvisorPPC_Tracktor' ) || 'off' === AdvisorPPC_Tracktor::user_choice() ) ) { continue; } $after = true; } else { continue; } $before = self::path_value( $path ); if ( $before === $after ) { continue; } if ( 'modules.tracktor' === $path ) { $ok = AdvisorPPC_Tracktor::enable(); self::fresh_reads(); $ok = $ok && true === self::path_value( $path ); } else { $ok = self::write_path( $path, $after ); } if ( ! $ok ) { ++$failed; continue; } $receipts[] = array( 'receipt_id' => $receipt_id, 'path' => $path, 'before' => $before, 'after' => $after, 'user_id' => (int) $user_id, 'applied_at' => $now, 'source' => 'detect', ); } $out['failed'] = $failed; if ( empty( $receipts ) ) { $out['status'] = $failed > 0 ? 'failed' : 'nothing'; return $out; } self::write_array( self::RECEIPTS_OPTION, self::cap_receipts( array_merge( self::receipts(), $receipts ) ) ); $state['settings_hash'] = self::settings_hash(); $state['plan_rev'] = self::new_rev(); $state['last_receipt'] = $receipt_id; $state['applied_at'] = $now; self::write_array( self::STATE_OPTION, $state ); return array( 'status' => $failed > 0 ? 'partial' : 'applied', 'receipt_id' => $receipt_id, 'changed' => count( $receipts ), 'failed' => $failed, ); } finally { self::release_lock(); } } /** * Revert one apply. Only rows whose current value still equals what the * apply wrote are reverted; a row changed since is left alone and marked * as a conflict on its receipt. * * @param string $receipt_id Receipt to revert. * @param int $user_id Who clicked. * @return array{reverted:int,skipped:int,busy:bool} */ public static function undo( $receipt_id, $user_id ) { $receipt_id = (string) $receipt_id; $out = array( 'reverted' => 0, 'skipped' => 0, 'busy' => false, ); if ( '' === $receipt_id ) { return $out; } if ( ! self::acquire_lock() ) { $out['busy'] = true; return $out; } try { self::fresh_reads(); $all = self::receipts(); $now = time(); foreach ( $all as $i => $r ) { if ( ! is_array( $r ) || ! isset( $r['receipt_id'], $r['path'] ) || $receipt_id !== (string) $r['receipt_id'] || ! empty( $r['undone_at'] ) || ! empty( $r['conflict_at'] ) ) { continue; } $path = (string) $r['path']; if ( ! self::is_allowed_path( $path ) ) { $all[ $i ]['conflict_at'] = $now; ++$out['skipped']; continue; } $is_module = 0 === strpos( $path, 'modules.' ); $after = $is_module ? (bool) $r['after'] : (string) $r['after']; $before = $is_module ? (bool) $r['before'] : (string) $r['before']; if ( self::path_value( $path ) !== $after || ! self::write_path( $path, $before ) ) { $all[ $i ]['conflict_at'] = $now; $all[ $i ]['conflict_by'] = (int) $user_id; ++$out['skipped']; continue; } if ( 'modules.tracktor' === $path && ! $before && class_exists( 'AdvisorPPC_Tracktor' ) ) { // Undo is a human switching it off; record it so nothing re-enables it. AdvisorPPC_Tracktor::set_user_choice( 'off' ); } $all[ $i ]['undone_at'] = $now; $all[ $i ]['undone_by'] = (int) $user_id; ++$out['reverted']; } if ( $out['reverted'] > 0 || $out['skipped'] > 0 ) { self::write_array( self::RECEIPTS_OPTION, self::cap_receipts( $all ) ); } if ( $out['reverted'] > 0 ) { $state = self::state(); if ( ! empty( $state ) ) { $state['applied_at'] = 0; $state['settings_hash'] = self::settings_hash(); $state['plan_rev'] = self::new_rev(); self::write_array( self::STATE_OPTION, $state ); } } return $out; } finally { self::release_lock(); } } /** * Receipt rows of the latest apply that can still be undone. * * @return array */ public static function undoable() { $state = self::state(); $id = isset( $state['last_receipt'] ) ? (string) $state['last_receipt'] : ''; if ( '' === $id ) { return array(); } $rows = array(); foreach ( self::receipts() as $r ) { if ( is_array( $r ) && isset( $r['receipt_id'] ) && $id === $r['receipt_id'] && empty( $r['undone_at'] ) && empty( $r['conflict_at'] ) ) { $rows[] = $r; } } return $rows; } /** * Receipt rows of the latest apply that undo had to leave as they were. * * @return array */ public static function undo_conflicts() { $state = self::state(); $id = isset( $state['last_receipt'] ) ? (string) $state['last_receipt'] : ''; $rows = array(); foreach ( self::receipts() as $r ) { if ( '' !== $id && is_array( $r ) && isset( $r['receipt_id'] ) && $id === $r['receipt_id'] && ! empty( $r['conflict_at'] ) ) { $rows[] = $r; } } return $rows; } /* * Site Health. */ /** * Duplicate loaders seen by the last scan. * * Two kinds: a vendor library loaded more than once by other sources, and * an ID that other sources already load and this plugin also has saved * (the scan leaves our own tags out, so that means two copies on the page). * * @param array $state Stored state. * @param array $settings Settings array. * @return string[] Human-readable lines. */ public static function duplicates( $state, $settings ) { $lines = array(); // Only a clean fetch counts: a cached copy may hold our own tags. if ( empty( $state['scanned_at'] ) || 'ok' !== ( isset( $state['status'] ) ? $state['status'] : '' ) ) { return $lines; } $names = array( 'gtag' => 'Google tag (gtag.js)', 'gtm' => 'Google Tag Manager', 'meta' => 'Meta Pixel', 'tiktok' => 'TikTok Pixel', 'uet' => 'Microsoft UET', ); foreach ( (array) ( isset( $state['loaders'] ) ? $state['loaders'] : array() ) as $key => $n ) { if ( (int) $n > 1 && isset( $names[ $key ] ) ) { /* translators: 1: tag library name, 2: number of copies. */ $lines[] = sprintf( __( '%1$s is loaded %2$d times on your home page.', 'advisorppc' ), $names[ $key ], (int) $n ); } } $tag_ids = isset( $settings['tag_ids'] ) && is_array( $settings['tag_ids'] ) ? $settings['tag_ids'] : array(); $tags_on = ! isset( $settings['modules']['tags'] ) || ! empty( $settings['modules']['tags'] ); if ( $tags_on ) { foreach ( (array) ( isset( $state['findings'] ) ? $state['findings'] : array() ) as $f ) { if ( ! empty( $f['live'] ) && isset( $tag_ids[ $f['platform'] ] ) && '' !== $tag_ids[ $f['platform'] ] && $tag_ids[ $f['platform'] ] === $f['id'] && ! self::is_externally_owned( $f['platform'], $f['id'] ) ) { /* translators: %s: a measurement ID. */ $lines[] = sprintf( __( '%s is loaded by another source and by AdvisorPPC.', 'advisorppc' ), $f['id'] ); } } } return $lines; } /** * Filter site_status_tests: add the tracking test. * * @param array $tests Tests. * @return array */ public static function register_site_health( $tests ) { if ( ! is_array( $tests ) ) { $tests = array(); } $tests['direct']['advisorppc_tracking'] = array( 'label' => __( 'AdvisorPPC tracking tags', 'advisorppc' ), 'test' => array( __CLASS__, 'site_health_test' ), ); return $tests; } /** * The Site Health result, from the last stored scan. No network call. * * @return array */ public static function site_health_test() { $lines = self::duplicates( self::state(), AdvisorPPC_Options::all() ); $result = array( 'label' => __( 'Each tracking tag loads once', 'advisorppc' ), 'status' => 'good', 'badge' => array( 'label' => __( 'Performance', 'advisorppc' ), 'color' => 'blue', ), 'description' => '

' . esc_html__( 'The last Auto setup scan found no tracking library loaded twice. Run a new scan from the AdvisorPPC screen after changing themes or plugins.', 'advisorppc' ) . '

', 'actions' => '', 'test' => 'advisorppc_tracking', ); if ( ! empty( $lines ) ) { $items = ''; foreach ( $lines as $line ) { $items .= '
  • ' . esc_html( $line ) . '
  • '; } $result['label'] = __( 'A tracking tag loads more than once', 'advisorppc' ); $result['status'] = 'recommended'; $result['description'] = '

    ' . esc_html__( 'A tag that loads twice can count each visit or conversion twice.', 'advisorppc' ) . '

      ' . $items . '
    '; $result['actions'] = '

    ' . esc_html__( 'Review in Auto setup', 'advisorppc' ) . '

    '; } return $result; } } add_action( 'advisorppc_loaded', array( 'AdvisorPPC_Detect', 'init' ) );