'', 'connected' => false, 'workspace_name' => '', 'plan' => '', 'modules' => array( 'tags' => true, 'shield' => false, 'server_events' => false, 'woo' => false, 'seo' => false, 'gravity' => false, 'forms' => false, // Off until an administrator enables it; never switched on by Connect. 'tracktor' => false, // Auto setup: local and read-only, works without an account. 'detect' => true, ), 'tag_ids' => array( 'ga4' => '', 'google_ads' => '', 'google_ads_label' => '', 'gtm' => '', 'meta' => '', 'tiktok' => '', 'uet' => '', 'linkedin' => '', 'pinterest' => '', ), 'consent_mode' => 'off', 'verification' => array( 'google' => '', 'bing' => '', 'pinterest' => '', 'meta' => '', ), 'video_tracking' => false, 'services' => array(), 'last_health' => array(), 'debug_log' => false, ); } /** * The complete settings array, defaults merged in. * * The site secret is NOT part of this array. Use get_secret(). * * @return array */ public static function all() { if ( null === self::$cache ) { $stored = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } self::$cache = self::merge( self::defaults(), $stored ); } return self::$cache; } /** * Read one setting. * * @param string $key Setting key. * @param mixed $default Value returned when the key is unknown. * @return mixed */ public static function get( $key, $default = null ) { $key = (string) $key; if ( 'site_secret' === $key ) { return self::get_secret(); } $all = self::all(); if ( array_key_exists( $key, $all ) ) { return $all[ $key ]; } return $default; } /** * Write one setting. * * @param string $key Setting key. * @param mixed $value Value. * @return bool True when the option row was written or already matched. */ public static function set( $key, $value ) { $key = (string) $key; if ( 'site_secret' === $key ) { return self::set_secret( $value ); } $all = self::all(); if ( in_array( $key, self::$map_keys, true ) && is_array( $value ) ) { $defaults = self::defaults(); $value = self::merge( $defaults[ $key ], $value ); } $all[ $key ] = $value; return self::save( $all ); } /** * Write several settings at once. * * @param array $values Key/value pairs. * @return bool */ public static function set_many( $values ) { if ( ! is_array( $values ) ) { return false; } $all = self::all(); foreach ( $values as $key => $value ) { if ( 'site_secret' === $key ) { self::set_secret( $value ); continue; } if ( in_array( $key, self::$map_keys, true ) && is_array( $value ) ) { $defaults = self::defaults(); $value = self::merge( $defaults[ $key ], $value ); } $all[ $key ] = $value; } return self::save( $all ); } /** * Remove one setting, restoring its default. * * @param string $key Setting key. * @return bool */ public static function delete( $key ) { $key = (string) $key; if ( 'site_secret' === $key ) { self::$cache = null; return delete_option( self::SECRET_KEY ); } $stored = get_option( self::OPTION_KEY, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } unset( $stored[ $key ] ); return self::save( $stored ); } /** * Persist the whole settings array. * * The settings row is autoloaded because it is read on every front-end * request by the tag loader. The secret is never in it. * * @param array $all Settings array. * @return bool */ protected static function save( $all ) { self::$cache = self::merge( self::defaults(), is_array( $all ) ? $all : array() ); $result = update_option( self::OPTION_KEY, self::$cache, true ); /** * Fires after the settings row has been written. * * @param array $settings The full settings array. */ do_action( 'advisorppc_settings_saved', self::$cache ); return $result; } /** * Seed defaults on activation without clobbering existing values. * * @return void */ public static function install_defaults() { $stored = get_option( self::OPTION_KEY, null ); if ( ! is_array( $stored ) ) { $stored = array(); } self::save( self::merge( self::defaults(), $stored ) ); } /** * Drop the runtime cache. Useful in tests and after a direct DB write. * * @return void */ public static function flush_cache() { self::$cache = null; } /* --------------------------------------------------------------------- * Secret handling * ------------------------------------------------------------------ */ /** * The site secret. * * Stored in its own option with autoload disabled so it is never loaded * into memory on a page that renders. It must never be echoed, never be * placed in a form value and never be sent anywhere except as an HMAC key. * * @return string */ public static function get_secret() { $secret = get_option( self::SECRET_KEY, '' ); return is_string( $secret ) ? $secret : ''; } /** * Store the site secret. * * @param string $secret Secret. * @return bool */ public static function set_secret( $secret ) { $secret = is_string( $secret ) ? trim( $secret ) : ''; if ( '' === $secret ) { return delete_option( self::SECRET_KEY ); } // Autoload explicitly false: this row must not be loaded on render. if ( false === get_option( self::SECRET_KEY, false ) ) { return add_option( self::SECRET_KEY, $secret, '', false ); } return update_option( self::SECRET_KEY, $secret, false ); } /** * Whether a site secret is stored. * * @return bool */ public static function has_secret() { return '' !== self::get_secret(); } /** * A display-safe stand-in for the secret. Never the secret itself. * * @return string */ public static function secret_placeholder() { return self::has_secret() ? str_repeat( "\xe2\x80\xa2", 24 ) : ''; } /** * The key that signs the browser handoff cookie. * * Per site, minted on activation and again lazily on first use so a site * that upgraded into this version gets one without a reactivation. Its * own non-autoloaded row, like the site secret, and never the site * secret itself. * * @return string 64 hex characters, or '' when no randomness is available. */ public static function handoff_secret() { $secret = get_option( self::HANDOFF_KEY, '' ); if ( is_string( $secret ) && 64 === strlen( $secret ) ) { return $secret; } $secret = self::mint_handoff_secret(); if ( '' === $secret ) { return ''; } // Autoload explicitly false: this row must not be loaded on render. if ( false === get_option( self::HANDOFF_KEY, false ) ) { add_option( self::HANDOFF_KEY, $secret, '', false ); } else { update_option( self::HANDOFF_KEY, $secret, false ); } return $secret; } /** * Mint one handoff key. * * @return string 64 hex characters, or '' when nothing can produce them. */ protected static function mint_handoff_secret() { if ( function_exists( 'random_bytes' ) ) { try { return bin2hex( random_bytes( 32 ) ); } catch ( Exception $e ) { // No entropy source: fall through to the WordPress generator. unset( $e ); } } if ( function_exists( 'wp_generate_password' ) ) { return hash( 'sha256', wp_generate_password( 64, true, true ) . microtime( true ) ); } return ''; } /* --------------------------------------------------------------------- * Manual shield choice * ------------------------------------------------------------------ */ /** * Whether the administrator has switched click protection by hand. * * Anything that enables the module automatically must check this first: * 'off' means a human deliberately turned it off and it must stay off. * * @return string 'on', 'off', or '' when the choice was never made. */ public static function shield_user_choice() { $choice = get_option( self::SHIELD_CHOICE_KEY, '' ); return in_array( $choice, array( 'on', 'off' ), true ) ? $choice : ''; } /** * Record that the administrator switched click protection by hand. * * @param string $choice 'on' or 'off'. Anything else clears the record. * @return bool */ public static function set_shield_user_choice( $choice ) { if ( ! in_array( $choice, array( 'on', 'off' ), true ) ) { return delete_option( self::SHIELD_CHOICE_KEY ); } if ( false === get_option( self::SHIELD_CHOICE_KEY, false ) ) { return add_option( self::SHIELD_CHOICE_KEY, $choice, '', false ); } return update_option( self::SHIELD_CHOICE_KEY, $choice, false ); } /* --------------------------------------------------------------------- * Convenience readers * ------------------------------------------------------------------ */ /** * The public site key. * * @return string */ public static function get_site_key() { $key = self::get( 'site_key', '' ); return is_string( $key ) ? $key : ''; } /** * Whether the workspace features are switched on for this build. * * True only when ADVISORPPC_WORKSPACE is defined as exactly true, so a * missing or odd value keeps every AdvisorPPC-host feature off. * * @return bool */ public static function workspace_enabled() { return defined( 'ADVISORPPC_WORKSPACE' ) && true === ADVISORPPC_WORKSPACE; } /** * Whether the site is connected to an AdvisorPPC workspace. * * Connected means: workspace features switched on, a site key, a site * secret and the connected flag. Anything that reaches the network must * check this first. * * @return bool */ public static function is_connected() { return self::workspace_enabled() && (bool) self::get( 'connected', false ) && '' !== self::get_site_key() && self::has_secret(); } /** * Whether a module is switched on. * * @param string $module One of tags, shield, server_events, woo, seo, * gravity, forms, tracktor, detect. * @return bool */ public static function module_enabled( $module ) { if ( ! self::workspace_enabled() && in_array( $module, array( 'server_events', 'woo', 'forms', 'shield', 'tracktor' ), true ) ) { return false; } $modules = self::get( 'modules', array() ); if ( ! is_array( $modules ) || ! isset( $modules[ $module ] ) ) { return false; } $enabled = (bool) $modules[ $module ]; /** * Filter whether a module is active. * * @param bool $enabled Whether the module is on. * @param string $module Module slug. */ return (bool) apply_filters( 'advisorppc_module_enabled', $enabled, $module ); } /** * A configured tag / measurement ID. * * @param string $network One of ga4, google_ads, google_ads_label, gtm, * meta, tiktok, uet, linkedin, pinterest. * @return string Empty string when not configured. */ public static function get_tag_id( $network ) { $ids = self::get( 'tag_ids', array() ); if ( ! is_array( $ids ) || empty( $ids[ $network ] ) ) { return ''; } return (string) $ids[ $network ]; } /** * All configured tag IDs, empty ones removed. * * @return array */ public static function get_tag_ids() { $ids = self::get( 'tag_ids', array() ); if ( ! is_array( $ids ) ) { return array(); } return array_filter( $ids, static function ( $value ) { return is_string( $value ) && '' !== $value; } ); } /** * Consent mode setting. * * @return string off|basic|advanced */ public static function consent_mode() { $mode = self::get( 'consent_mode', 'off' ); return in_array( $mode, array( 'off', 'basic', 'advanced' ), true ) ? $mode : 'off'; } /** * Cached health payload. * * @return array */ /** * A service base URL from the directory the server returned at exchange. * * The exchange response carries a `services` block ({tracking, shield, * gravity}) so a host can move without shipping a plugin update. A module * asks here first and falls back to its own compiled-in default only when * this site has never connected. * * The stored entry is re-checked against the service allowlist on every * read, not only when it was written. An option row can be edited by hand, * restored from a backup, or predate the allowlist, and this accessor is * what every module (tracking, Shield, Gravity) reads. Checking here means * no module can be pointed at a host the exchange would have refused. * * @param string $service tracking|shield|gravity. * @return string Untrailingslashed base URL, or '' when unknown or * not an allowed https origin. */ public static function service_url( $service ) { $service = sanitize_key( $service ); $services = self::get( 'services', array() ); if ( ! is_array( $services ) || ! isset( $services[ $service ] ) || ! is_scalar( $services[ $service ] ) ) { return ''; } $url = esc_url_raw( trim( (string) $services[ $service ] ) ); if ( '' === $url || 0 !== strpos( $url, 'https://' ) ) { return ''; } if ( class_exists( 'AdvisorPPC_Connect' ) && method_exists( 'AdvisorPPC_Connect', 'is_allowed_service_url' ) && ! AdvisorPPC_Connect::is_allowed_service_url( $url ) ) { return ''; } return untrailingslashit( $url ); } public static function last_health() { $health = self::get( 'last_health', array() ); return is_array( $health ) ? $health : array(); } /* --------------------------------------------------------------------- * Helpers * ------------------------------------------------------------------ */ /** * Recursive-ish merge: associative defaults are merged one level deep, * everything else is replaced. * * @param array $defaults Defaults. * @param array $values Stored values. * @return array */ protected static function merge( $defaults, $values ) { $out = $defaults; foreach ( $values as $key => $value ) { if ( isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) && is_array( $value ) && in_array( $key, self::$map_keys, true ) ) { $out[ $key ] = array_merge( $defaults[ $key ], $value ); continue; } $out[ $key ] = $value; } return $out; } }