send_ip = $send_ip; } /** * The tracker's anonymous visitor id. * * @return string|null */ public function anonymous_id(): ?string { foreach ( array( self::COOKIE_DEVICE, self::COOKIE_DEVICE_LEGACY ) as $name ) { $value = $this->cookie( $name ); // The id is a UUID; anything else is forged or corrupted and is not worth forwarding, // because sending it would create a junk visitor rather than join an existing one. if ( null !== $value && preg_match( '/^[A-Za-z0-9._-]{8,64}$/', $value ) ) { return $value; } } return null; } /** * The `context` object for an S2S payload. * * @return array */ public function to_array(): array { $context = array(); $first = Touch::from_cookie( $this->json_cookie( self::COOKIE_FIRST_TOUCH ) ); $last = Touch::from_cookie( $this->json_cookie( self::COOKIE_LAST_TOUCH ) ); if ( null !== $first ) { $context['first_touch'] = $first->to_array(); } if ( null !== $last ) { $context['last_touch'] = $last->to_array(); } $user_agent = $this->server( 'HTTP_USER_AGENT' ); if ( '' !== $user_agent ) { $context['user_agent'] = $user_agent; } // The IP decides the country, region and city on the conversion. It is also personal data, so // it is a setting rather than an assumption, and a site can drop it entirely with the filter // below without giving up the rest of the context. if ( $this->send_ip ) { $ip = $this->client_ip(); if ( '' !== $ip ) { $context['ip'] = $ip; } } /** * Filters the visitor context sent with server-side events. * * @since 1.0.0 * * @param array $context Context object. */ return (array) apply_filters( 'adpix_analytics_visitor_context', $context ); } /** * Whether the visitor has opted out of measurement. * * @return bool */ public function has_opted_out(): bool { return '1' === $this->cookie( self::COOKIE_OPT_OUT ); } /** * Whether a consent category is permitted. * * Absent a consent cookie the answer is yes: the consent module may simply not be enabled on the * property, and treating "no banner" as "no consent" would silently disable measurement on every * site that never turned it on. * * @param string $category Consent category. * @return bool */ public function allows( string $category ): bool { $decoded = $this->json_cookie( self::COOKIE_CONSENT ); if ( ! is_array( $decoded ) || ! isset( $decoded['a'] ) || ! is_array( $decoded['a'] ) ) { return true; } return in_array( $category, array_map( 'strval', $decoded['a'] ), true ); } // --------------------------------------------------------------------- // Superglobal access, in one place, always unslashed and shape-checked. // --------------------------------------------------------------------- /** * One cookie as a sanitised scalar string. * * @param string $name Cookie name. * @return string|null */ private function cookie( string $name ): ?string { if ( empty( $_COOKIE[ $name ] ) || ! is_scalar( $_COOKIE[ $name ] ) ) { return null; } $value = sanitize_text_field( wp_unslash( (string) $_COOKIE[ $name ] ) ); return '' !== $value ? $value : null; } /** * One cookie decoded as JSON. * * Deliberately not run through sanitize_text_field() first — that would mangle the JSON before it * could be parsed. The decoded fields are sanitised individually by whoever consumes them. * * @param string $name Cookie name. * @return mixed Null when absent or not valid JSON. */ private function json_cookie( string $name ) { if ( empty( $_COOKIE[ $name ] ) || ! is_scalar( $_COOKIE[ $name ] ) ) { return null; } $raw = wp_unslash( (string) $_COOKIE[ $name ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON is parsed then sanitised field by field. if ( strlen( $raw ) > 4096 ) { return null; // A touch cookie is a few hundred bytes; this one is not ours. } $decoded = json_decode( $raw, true ); return JSON_ERROR_NONE === json_last_error() ? $decoded : null; } /** * One $_SERVER value as a sanitised string. * * @param string $key Server key. * @return string */ private function server( string $key ): string { if ( empty( $_SERVER[ $key ] ) || ! is_scalar( $_SERVER[ $key ] ) ) { return ''; } return sanitize_text_field( wp_unslash( (string) $_SERVER[ $key ] ) ); } /** * The request IP. * * REMOTE_ADDR only. X-Forwarded-For is set by the client on a site that is not behind a proxy * that overwrites it, so reading it by default would let any visitor choose the country their * own conversion is attributed to. A site that does sit behind a trusted proxy can supply the * real address through the filter. * * @return string */ private function client_ip(): string { $ip = $this->server( 'REMOTE_ADDR' ); /** * Filters the IP forwarded with server-side events. * * @since 1.0.0 * * @param string $ip The address from REMOTE_ADDR. */ $ip = (string) apply_filters( 'adpix_analytics_client_ip', $ip ); return false !== filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : ''; } }