fallback pixel. * There is no new server code behind this module: both endpoints already ship * in the Shield connector. * * collector https://shield.advisorppc.ai/shield/v1/s.js?k= * fallback https://shield.advisorppc.ai/shield/v1/p.gif?k= * * Unlike modules 1's tags, Shield genuinely cannot work without a site key, * because the key is how the ingest resolves which workspace a click belongs * to. When there is no key the module simply stays off and says so in plain * words. It never nags, never blocks anything else, and shows no upgrade * prompt: everything else in this plugin keeps working without an account. * * The module is OFF until an administrator switches it on. Connecting a * workspace does not switch it on: this module is the one thing in the plugin * that sends a visitor's IP address and user agent to a third party, and that * is a decision an administrator makes deliberately, not a side effect of * pressing Connect (ROADMAP alignment ruling 5). A hand toggle either way is * recorded in `advisorppc_shield_user_choice`. * * FOR LANE A - the settings screen wants: * AdvisorPPC_Shield::status() cheap, no network, safe on every render * * The server itself never contacts the Shield host. Only a visitor's browser * does, by loading the collector while the module is active. * * @package AdvisorPPC */ class AdvisorPPC_Shield { /** Script handle. */ const HANDLE = 'advisorppc-shield'; /** Default Shield host. */ const DEFAULT_BASE = 'https://shield.advisorppc.ai'; /** Path prefix on that host. */ const PATH_PREFIX = '/shield/v1'; /** * Records that a human, not this plugin, last decided whether Shield is on. * * Absent nobody has ever touched the switch by hand * 'on' an administrator turned it on * 'off' an administrator turned it off * * Nothing in the plugin switches this module on by itself, so this record * is an audit trail rather than a veto. It is kept because it is the only * durable answer to "did a human ask for the visitor's IP address to be * sent to Shield, or did something else decide that?". * * Non-autoloaded: it is only ever read and written on a settings save, an * admin-only path. */ const USER_CHOICE_OPTION = 'advisorppc_shield_user_choice'; /** @var bool */ private static $booted = false; /** * Register hooks. Idempotent. * * @return void */ public static function init() { if ( self::$booted ) { return; } self::$booted = true; add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue' ), 7 ); add_action( 'wp_footer', array( __CLASS__, 'print_noscript' ), 22 ); // Deliberately NOT hooked to `advisorppc_connected`. Connect must not // switch a module on; see the class docblock. // Notice a hand toggle. `pre_update_option_*` is the only place both // the old and the new value are visible; `advisorppc_settings_saved` // fires after the write, when the previous state is already gone. add_filter( 'pre_update_option_' . self::settings_option_name(), array( __CLASS__, 'record_user_choice' ), 10, 2 ); } /** * The name of lane A's settings option row. * * Tested with defined() rather than class_exists(), because a class can be * present without the constant and reaching for a missing class constant * is a fatal error, not a warning. * * @return string */ private static function settings_option_name() { if ( defined( 'AdvisorPPC_Options::OPTION_KEY' ) ) { return (string) constant( 'AdvisorPPC_Options::OPTION_KEY' ); } return 'advisorppc_settings'; } /** * Read the shield switch out of a settings array. * * @param mixed $settings Settings array, or anything else. * @return bool|null Null when the array carries no opinion at all. */ private static function shield_flag( $settings ) { if ( ! is_array( $settings ) || empty( $settings['modules'] ) || ! is_array( $settings['modules'] ) ) { return null; } if ( ! array_key_exists( 'shield', $settings['modules'] ) ) { return null; } return ! empty( $settings['modules']['shield'] ); } /** * Is this write an administrator submitting our own settings form? * * This is the whole point of the check. A programmatic write, a migration * or another plugin flipping the module is NOT a user decision and must not * be recorded as one. Only a human posting the settings form counts. * * We read no POST value, only test that our option was submitted at all. * Lane A owns the nonce and capability checks on that form; the capability * test here is a second lock so an unprivileged POST cannot reach this. * * @return bool */ private static function is_admin_form_submission() { if ( ! is_admin() || empty( $_POST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- presence test only, no value is read; lane A verifies the nonce on its own form. return false; } if ( ! current_user_can( 'manage_options' ) ) { return false; } return isset( $_POST[ self::settings_option_name() ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- presence test only, no value is read; lane A verifies the nonce on its own form. } /** * Remember that a human moved the switch. * * Records a choice only when all three hold: * - an administrator is posting the settings form * - there was a previous state to compare against, so activation, which * has no old value, is never mistaken for a decision * - the value actually changed * * @param mixed $value Settings about to be written. * @param mixed $old_value Settings currently stored. * @return mixed The unmodified $value. */ public static function record_user_choice( $value, $old_value ) { if ( ! self::is_admin_form_submission() ) { return $value; } $new_on = self::shield_flag( $value ); $old_on = self::shield_flag( $old_value ); if ( null === $new_on || null === $old_on || $new_on === $old_on ) { return $value; } update_option( self::USER_CHOICE_OPTION, $new_on ? 'on' : 'off', false ); return $value; } /** * Read an option without hard-depending on the options class. * * @param string $key Option key. * @param mixed $default Fallback. * @return mixed */ private static function opt( $key, $default = null ) { if ( ! class_exists( 'AdvisorPPC_Options' ) ) { return $default; } return AdvisorPPC_Options::get( $key, $default ); } /** * The Shield host, without a trailing slash. * * @return string */ public static function base_url() { /** * Filters the Shield host. Useful for a staging deployment or a * first-party proxy. * * @since 0.1.0 * @param string $base Default https://shield.advisorppc.ai */ // The server is authoritative. `services.shield` comes back from // /wp/v1/site/exchange precisely so a host can move without shipping a // plugin update; DEFAULT_BASE is only the answer for a site that has // never connected. // // The entry is re-checked against the service allowlist here, on the // read, even though AdvisorPPC_Options::service_url() already does so. // This script ships the visitor's IP address and user agent to whatever // host it names, so a stored value that fails the allowlist (a hand // edit, a pre-allowlist row) must never reach the page. Falling back // to DEFAULT_BASE is the same answer the tracking path gives. $directory = class_exists( 'AdvisorPPC_Options' ) ? (string) AdvisorPPC_Options::service_url( 'shield' ) : ''; if ( '' !== $directory && ! self::is_allowed_base( $directory ) ) { $directory = ''; } $default = '' !== $directory ? $directory : self::DEFAULT_BASE; $base = apply_filters( 'advisorppc_shield_base', $default ); $base = untrailingslashit( esc_url_raw( trim( (string) $base ) ) ); // A filter is site code and any plugin on the site can add one. This // script ships the visitor's IP address and user agent to whatever host // it names, and readme.txt promises that host is always under // advisorppc.com or advisorppc.ai. So the filtered value // is held to the same allowlist a directory entry is, not merely to // https. A value that fails falls back to the directory entry or the // compiled-in host. if ( $base === untrailingslashit( $default ) ) { return untrailingslashit( $default ); } if ( '' === $base || ! self::is_allowed_base( $base ) ) { if ( class_exists( 'AdvisorPPC_Logger' ) ) { AdvisorPPC_Logger::error( 'The advisorppc_shield_base filter named a host that is not an AdvisorPPC service host; the default was used instead.', array( 'rejected' => $base ) ); } $base = $default; } return untrailingslashit( $base ); } /** * Whether a directory-supplied Shield base passes the service allowlist. * * @param string $url Candidate base URL. * @return bool */ private static function is_allowed_base( $url ) { if ( class_exists( 'AdvisorPPC_Connect' ) && method_exists( 'AdvisorPPC_Connect', 'is_allowed_service_url' ) ) { return (bool) AdvisorPPC_Connect::is_allowed_service_url( $url ); } // Without the connect class the allowlist cannot be consulted, so the // directory entry is not trusted at all and the compiled-in host wins. return false; } /** * The site key, validated. Public value, safe to print. * * @return string Empty when unset or malformed. */ public static function site_key() { $key = self::opt( 'site_key', '' ); if ( ! is_string( $key ) ) { return ''; } $key = trim( $key ); return preg_match( '/^[A-Za-z0-9_\-]{8,128}$/', $key ) ? $key : ''; } /** * Collector URL for this site. * * @return string Empty when there is no site key. */ public static function script_url() { $key = self::site_key(); if ( '' === $key ) { return ''; } return self::base_url() . self::PATH_PREFIX . '/s.js?k=' . rawurlencode( $key ); } /** *