handle => comma separated required signals */ private static $gated = array(); /** * Register hooks. Idempotent. Called once by the plugin loader. * * @return void */ public static function init() { if ( self::$booted ) { return; } self::$booted = true; // Priority 1: the bootstrap must be registered before anything can // declare it as a dependency. add_action( 'wp_enqueue_scripts', array( __CLASS__, 'register' ), 1 ); add_filter( 'script_loader_tag', array( __CLASS__, 'gate_script_tag' ), 10, 2 ); } /** * Read an option without hard-depending on the options class being loaded. * * @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 ); } /** * Current consent mode, always one of the three known values. * * @return string */ public static function mode() { $mode = self::opt( 'consent_mode', self::MODE_OFF ); if ( ! in_array( $mode, array( self::MODE_OFF, self::MODE_BASIC, self::MODE_ADVANCED ), true ) ) { $mode = self::MODE_OFF; } return $mode; } /** * Is consent mode switched on at all? * * @return bool */ public static function is_enabled() { return self::MODE_OFF !== self::mode(); } /** * The default consent state sent before any tag loads. * * Filter: `advisorppc_consent_state` * @param array $state Map of signal => 'granted'|'denied', plus the * optional integer key `wait_for_update`. * @param string $mode 'basic' or 'advanced'. * * Values that are not exactly 'granted' or 'denied' are dropped, so a * broken filter degrades to Google's own default rather than to an * accidental grant. * * @return array */ public static function default_state() { $state = array( 'ad_storage' => 'denied', 'ad_user_data' => 'denied', 'ad_personalization' => 'denied', 'analytics_storage' => 'denied', 'functionality_storage' => 'granted', 'personalization_storage' => 'denied', 'security_storage' => 'granted', 'wait_for_update' => 500, ); /** * Filters the Consent Mode v2 default state. * * @since 0.1.0 * @param array $state Signal map. * @param string $mode Current consent mode. */ $state = apply_filters( 'advisorppc_consent_state', $state, self::mode() ); return self::sanitize_state( is_array( $state ) ? $state : array() ); } /** * Optional region scoped default states, emitted as extra * gtag('consent','default',{...,region:[...]}) calls before the global one. * * Filter: `advisorppc_consent_region_defaults` * Expects a list of arrays, each a signal map plus a `region` key holding * a list of ISO 3166-2 codes, e.g. array( 'US-CA', 'GB' ). * * @return array */ public static function region_defaults() { /** * Filters region scoped Consent Mode v2 defaults. * * @since 0.1.0 * @param array $regions List of signal maps, each carrying a `region` key. * @param string $mode Current consent mode. */ $regions = apply_filters( 'advisorppc_consent_region_defaults', array(), self::mode() ); if ( ! is_array( $regions ) ) { return array(); } $out = array(); foreach ( $regions as $entry ) { if ( ! is_array( $entry ) || empty( $entry['region'] ) || ! is_array( $entry['region'] ) ) { continue; } $clean = self::sanitize_state( $entry ); $codes = array(); foreach ( $entry['region'] as $code ) { $code = strtoupper( preg_replace( '/[^A-Za-z\-]/', '', (string) $code ) ); if ( '' !== $code && strlen( $code ) <= 6 ) { $codes[] = $code; } } if ( $codes && $clean ) { $clean['region'] = array_values( array_unique( $codes ) ); $out[] = $clean; } } return $out; } /** * Keep only known signals with known values. * * @param array $state Raw state. * @return array */ private static function sanitize_state( $state ) { $out = array(); foreach ( self::SIGNALS as $signal ) { if ( isset( $state[ $signal ] ) && in_array( $state[ $signal ], array( 'granted', 'denied' ), true ) ) { $out[ $signal ] = $state[ $signal ]; } } if ( isset( $state['wait_for_update'] ) ) { $wait = absint( $state['wait_for_update'] ); if ( $wait > 0 && $wait <= 10000 ) { $out['wait_for_update'] = $wait; } } return $out; } /** * Register the bootstrap script. It is enqueued implicitly, because every * tag script declares it as a dependency. * * @return void */ public static function register() { wp_register_script( self::HANDLE, self::asset_url( 'public/js/consent.js' ), array(), self::asset_version(), false ); $config = array( 'mode' => self::mode(), 'defaults' => self::is_enabled() ? self::default_state() : array(), 'regions' => self::is_enabled() ? self::region_defaults() : array(), 'cookie' => self::COOKIE_NAME, 'cookieDays' => self::COOKIE_DAYS, 'secure' => is_ssl(), /** * Filters whether to bridge the WordPress Consent API * (`wp_has_consent` / `wp_listen_for_consent_change`), which is what * Complianz, CookieYes, Borlabs and others implement. * * @since 0.1.0 * @param bool $enabled Default true. */ 'bridgeWpConsentApi' => (bool) apply_filters( 'advisorppc_bridge_wp_consent_api', true ), ); wp_add_inline_script( self::HANDLE, 'window.advisorppcConsent = ' . wp_json_encode( $config ) . ';', 'before' ); } /** * The visitor's ACTUAL consent decision, or null when nobody has asked. * * FOR LANE C: the envelope's `consent` block. Per the orchestrator's * ruling, omit the block entirely when this returns null, because lane D * treats a missing block as "unknown" and an all-denied block as a real * refusal. Those are different facts and must not be conflated. * * $consent = AdvisorPPC_Consent::visitor_state(); * if ( null !== $consent ) { * $event['consent'] = $consent; * } * * Returns null when consent mode is off (we emit no signals, so we know * nothing), when no CMP has spoken, or when the cookie is unreadable. * * @return array|null */ public static function visitor_state() { if ( ! self::is_enabled() ) { return null; } if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) { return null; } $raw = wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised by sanitize_state() below. if ( ! is_string( $raw ) || strlen( $raw ) > 1024 ) { return null; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { return null; } $state = self::sanitize_state( $data ); unset( $state['wait_for_update'] ); return empty( $state ) ? null : $state; } /** * Mark a script handle as consent gated. Called by AdvisorPPC_Tags at * enqueue time, before `script_loader_tag` runs. * * @param string $handle Registered script handle. * @param string $signals Comma separated Consent Mode v2 signals that must * ALL be granted before the script may execute. * @return void */ public static function register_gated( $handle, $signals ) { if ( ! self::is_enabled() ) { return; } self::$gated[ (string) $handle ] = (string) $signals; } /** * Neutralise gated script tags so the browser parses but does not execute * them. `public/js/consent.js` promotes them back to real scripts the * moment the required signals are granted. * * @param string $tag Full script tag markup. * @param string $handle Script handle. * @return string */ public static function gate_script_tag( $tag, $handle ) { if ( ! isset( self::$gated[ $handle ] ) ) { return $tag; } $signals = self::$gated[ $handle ]; // Drop whatever type attribute WordPress emitted, then set ours. $tag = preg_replace( '/\stype=(["\'])[^"\']*\1/', '', $tag ); // Tolerate leading whitespace another filter may have added. $tag = preg_replace( '/^(\s*)