*/ private const CONSTANTS = array( 'property_id' => array( 'ADPIX_ANALYTICS_PROPERTY_ID', 'ADPIX_PROPERTY_ID' ), 'measurement_id' => array( 'ADPIX_ANALYTICS_MEASUREMENT_ID', 'ADPIX_MEASUREMENT_ID' ), 'server_key' => array( 'ADPIX_ANALYTICS_SERVER_KEY', 'ADPIX_SERVER_KEY' ), 'api_token' => array( 'ADPIX_ANALYTICS_API_TOKEN' ), ); /** * The AdPix hosts, each with the wp-config.php constants that may replace it, first match wins. * * Not settings: every hosted property uses these same addresses, and a field for each only gave * site owners a way to paste the wrong one. The platform splits the work — the server-to-server * and reporting APIs are on the api host, the tag script on the CDN, and the browser's own events * on the collect host, which is the only one that accepts them. * * The constants remain for a self-hosted platform or a first-party tag gateway. A single-host * self-hosted install set only the collector constant before the hosts were split, so the browser * endpoint falls back to it. * * @var array */ private const HOSTS = array( 'collector' => array( 'https://api.adpix.io', array( 'ADPIX_ANALYTICS_COLLECTOR_URL', 'ADPIX_COLLECTOR' ) ), 'tracker' => array( 'https://cdn.adpix.net', array( 'ADPIX_ANALYTICS_TRACKER_URL', 'ADPIX_TRACKER' ) ), 'endpoint' => array( 'https://collect.adpix.net', array( 'ADPIX_ANALYTICS_ENDPOINT', 'ADPIX_ANALYTICS_COLLECTOR_URL', 'ADPIX_COLLECTOR' ) ), 'api' => array( 'https://api.adpix.io', array( 'ADPIX_ANALYTICS_API_URL' ) ), ); /** * Settings shown only in the advanced mode of the settings screen. * * Kept here, next to the schema, because two places must agree on it: the form leaves these out * in simple mode, and the sanitizer must then keep their stored values rather than read the * absence as "cleared" or "unticked". * * @var string[] */ private const ADVANCED_ONLY = array( 'use_hmac', 'force_ssl', 'send_ip', 'default_cc', 'custom_tag_code', 'custom_code', 'custom_code_location', ); /** * Every setting with its default, which doubles as the schema: a key absent from here is not a * setting and is dropped on save. * * @var array */ private const DEFAULTS = array( 'property_id' => '', 'measurement_id' => '', 'server_key' => '', 'api_token' => '', 'use_hmac' => false, 'force_ssl' => false, 'default_cc' => '', 'inject_tag' => true, 'track_commerce' => true, 'track_users' => true, 'track_forms' => true, 'respect_consent' => true, 'send_ip' => true, 'tag_location' => 'head', 'tag_admin_area' => false, 'exclude_roles' => array(), 'exclude_post_ids' => '', 'exclude_search_archive' => false, 'custom_tag_code' => '', 'custom_code' => '', 'custom_code_location' => 'head', 'debug' => false, 'delete_on_uninstall' => false, ); /** * Settings whose value must be one of a fixed set, with the first entry as the fallback. Kept * here rather than in the sanitizer so the form and the validator cannot drift apart. * * @var array */ private const CHOICES = array( 'tag_location' => array( 'head', 'footer' ), 'custom_code_location' => array( 'head', 'footer' ), ); /** * Resolved values. * * @var array */ private $values; /** * Keys whose value came from a constant and so cannot be edited in the admin. * * @var string[] */ private $locked; /** * @param array $values Resolved values. * @param string[] $locked Keys fixed by a wp-config constant. */ private function __construct( array $values, array $locked = array() ) { $this->values = $values; $this->locked = $locked; } /** * Build from a stored array, applying defaults and any wp-config overrides. * * @param array $stored Raw option value. * @return self */ public static function from_array( array $stored ): self { $values = array(); $locked = array(); foreach ( self::DEFAULTS as $key => $default ) { $value = array_key_exists( $key, $stored ) ? $stored[ $key ] : $default; foreach ( self::CONSTANTS[ $key ] ?? array() as $constant ) { if ( defined( $constant ) && '' !== (string) constant( $constant ) ) { $value = constant( $constant ); $locked[] = $key; break; } } if ( is_bool( $default ) ) { $value = (bool) $value; } elseif ( is_array( $default ) ) { $value = is_array( $value ) ? array_values( array_map( 'strval', $value ) ) : array(); } elseif ( isset( self::CHOICES[ $key ] ) && ! in_array( (string) $value, self::CHOICES[ $key ], true ) ) { // A value outside the set can only come from a hand-edited option row or an older // schema. Falling back to the first choice keeps the form and the behaviour agreeing. $value = self::CHOICES[ $key ][0]; } $values[ $key ] = $value; } return new self( $values, $locked ); } /** * The permitted values for a fixed-choice setting. * * @param string $key Setting name. * @return string[] Empty when the setting is not a fixed-choice one. */ public static function choices( string $key ): array { return self::CHOICES[ $key ] ?? array(); } /** * The schema, for callers that enumerate settings (the sanitizer, the admin form). * * @return array */ public static function defaults(): array { return self::DEFAULTS; } /** * Settings the simple mode leaves off the form. * * @return string[] */ public static function advanced_only(): array { return self::ADVANCED_ONLY; } /** * One value. * * @param string $key Setting name. * @return mixed Null when the key is not part of the schema. */ public function get( string $key ) { return $this->values[ $key ] ?? null; } /** * One value as a trimmed string. * * @param string $key Setting name. * @return string */ public function str( string $key ): string { $value = $this->values[ $key ] ?? ''; return is_scalar( $value ) ? trim( (string) $value ) : ''; } /** * One value as a list of strings. * * @param string $key Setting name. * @return string[] */ public function arr( string $key ): array { $value = $this->values[ $key ] ?? array(); return is_array( $value ) ? array_values( array_map( 'strval', $value ) ) : array(); } /** * One value as a boolean. * * @param string $key Setting name. * @return bool */ public function flag( string $key ): bool { return ! empty( $this->values[ $key ] ); } /** * Whether a wp-config constant fixes this key, so the admin form must show it read-only. * * @param string $key Setting name. * @return bool */ public function is_locked( string $key ): bool { return in_array( $key, $this->locked, true ); } /** * A copy with some values replaced. The object itself never changes. * * @param array $changes Values to override. * @return self */ public function with( array $changes ): self { return new self( array_merge( $this->values, array_intersect_key( $changes, self::DEFAULTS ) ), $this->locked ); } /** * The storable form. * * @return array */ public function to_array(): array { return $this->values; } // --------------------------------------------------------------------- // Derived questions the rest of the plugin actually asks. // --------------------------------------------------------------------- /** * Server-to-server base URL, without a trailing slash: where orders, sign-ups and leads are sent. * * @return string */ public function collector_url(): string { return $this->host( 'collector' ); } /** * Where the browser tag sends its own events, without a trailing slash. * * @return string */ public function endpoint_url(): string { return $this->host( 'endpoint' ); } /** * One AdPix host: a wp-config.php override when there is one, otherwise the hosted address. * * @param string $which Key of self::HOSTS. * @return string */ private function host( string $which ): string { list( $url, $constants ) = self::HOSTS[ $which ]; foreach ( $constants as $constant ) { if ( defined( $constant ) && '' !== (string) constant( $constant ) ) { $url = (string) constant( $constant ); break; } } return $this->secure( untrailingslashit( $url ) ); } /** * Upgrade a URL to HTTPS when the site has asked for it. * * Applied at the point of use rather than on save, so turning the setting off restores the * address the owner actually typed instead of leaving a rewritten one behind. * * @param string $url Configured address. * @return string */ private function secure( string $url ): string { if ( '' === $url || ! $this->flag( 'force_ssl' ) ) { return $url; } return (string) preg_replace( '#^http://#i', 'https://', $url ); } /** * Where the tag JavaScript is served from, without a trailing slash. * * @return string */ public function tracker_url(): string { return $this->host( 'tracker' ); } /** * Whether server-to-server calls can be made at all. * * @return bool */ public function is_connected(): bool { return '' !== $this->str( 'property_id' ) && '' !== $this->str( 'server_key' ); } /** * Reporting API base URL, without a trailing slash. Always the hosted API unless a wp-config.php * constant names a self-hosted one. * * The AdPix dashboard shows this address with its version on the end (https://api.adpix.io/v1), * and that is exactly what an owner will copy into the constant. The report client adds /v1 * itself, so a pasted version is dropped here rather than turned into /v1/v1 and a 404 nobody can * explain. * * @return string */ public function api_base_url(): string { return (string) preg_replace( '#/v1$#i', '', $this->host( 'api' ) ); } /** * Whether the statistics screen has what it needs to ask for a report. * * Deliberately no mention of the server key. That credential can write to the property and never * leaves the collector's own requests; reports are read with a token that can do nothing else. * A site can therefore show its statistics without the write credential being configured at all, * and can send measurements without granting anybody read access to them. * * @return bool */ public function can_report(): bool { return '' !== $this->api_base_url() && '' !== $this->str( 'property_id' ) && '' !== $this->str( 'api_token' ); } /** * Whether the browser tag should be printed. * * @return bool */ public function can_inject_tag(): bool { return $this->flag( 'inject_tag' ) && '' !== $this->str( 'measurement_id' ) && '' !== $this->tracker_url(); } /** * Whether the tag belongs at the end of the document rather than in the head. * * The head is what the platform recommends, because a tag that loads later measures fewer of the * visitors who leave quickly. The footer is offered because on a slow site that trade is * sometimes worth making. * * @return bool */ public function tag_in_footer(): bool { return 'footer' === $this->str( 'tag_location' ); } /** * Whether the site's own extra markup belongs at the end of the document. * * @return bool */ public function custom_code_in_footer(): bool { return 'footer' === $this->str( 'custom_code_location' ); } /** * Post IDs the tag must not appear on. * * @return int[] */ public function excluded_post_ids(): array { $raw = preg_split( '/[\s,]+/', $this->str( 'exclude_post_ids' ), -1, PREG_SPLIT_NO_EMPTY ); $ids = array_map( 'absint', is_array( $raw ) ? $raw : array() ); return array_values( array_unique( array_filter( $ids ) ) ); } }