>|null */ private static ?array $cache = null; /** * An empty attribution record, used as the shape everything else follows. * * @return array> */ public static function empty_record(): array { return array( Fields::TOUCH_FIRST => array(), Fields::TOUCH_LAST => array(), ); } /** * The cookie name currently configured. */ public static function cookie_name(): string { return (string) Settings::get( 'cookie_name' ); } /** * The visitor's stored attribution, or empty touches when nothing is stored. * * @return array> */ public static function current(): array { if ( null === self::$cache ) { // Validated in parse(); JSON cannot be sanitised before it is decoded. $raw = isset( $_COOKIE[ self::cookie_name() ] ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized ? wp_unslash( $_COOKIE[ self::cookie_name() ] ) : null; self::$cache = self::parse( $raw ); } return self::$cache; } /** * Whether anything at all has been captured for this visitor. */ public static function has_data(): bool { $record = self::current(); return ! empty( $record[ Fields::TOUCH_FIRST ] ) || ! empty( $record[ Fields::TOUCH_LAST ] ); } /** * Resolve a single form token, for example last_source. */ public static function token_value( string $token ): string { $parsed = Fields::parse_token( $token ); if ( null === $parsed ) { return ''; } [ $touch, $key ] = $parsed; return self::current()[ $touch ][ $key ] ?? ''; } /** * Every known token mapped to its current value. * * @return array */ public static function token_values(): array { $values = array(); foreach ( array_keys( Fields::tokens() ) as $token ) { $values[ $token ] = self::token_value( $token ); } return $values; } /** * Forget the parsed cookie so the next read re-parses it. */ public static function flush_cache(): void { self::$cache = null; } /** * Turn a raw cookie value into a validated attribution record. * * @param string|null $raw Raw cookie value. * @return array> */ private static function parse( ?string $raw ): array { if ( null === $raw || '' === $raw ) { return self::empty_record(); } return self::validate( json_decode( urldecode( wp_unslash( $raw ) ), true ) ); } /** * Validate an already decoded payload against the schema, keeping only * recognised keys. * * @param mixed $decoded Decoded JSON, or anything else untrusted. * @return array> */ public static function validate( $decoded ): array { if ( ! is_array( $decoded ) ) { return self::empty_record(); } if ( (int) ( $decoded['v'] ?? 0 ) !== self::SCHEMA ) { return self::empty_record(); } $record = self::empty_record(); foreach ( array_keys( $record ) as $touch ) { $record[ $touch ] = self::sanitize_touch( $decoded[ $touch ] ?? array() ); } return $record; } /** * Keys holding a URL rather than a label. * * Not passed through sanitize_text_field(), which deletes percent encoded * characters. */ private const URL_KEYS = array( 'landing_page', 'referrer' ); /** * Keep only recognised keys, sanitised and length capped. * * @param mixed $touch Raw touch payload. * @return array */ private static function sanitize_touch( $touch ): array { if ( ! is_array( $touch ) ) { return array(); } $allowed = Fields::keys(); $clean = array(); foreach ( $touch as $key => $value ) { $key = (string) $key; if ( ! isset( $allowed[ $key ] ) || ! is_scalar( $value ) ) { continue; } $value = self::sanitize_value( $key, (string) $value ); if ( '' === $value ) { continue; } $clean[ $key ] = mb_substr( $value, 0, self::MAX_VALUE_LENGTH ); } return $clean; } /** * Sanitise one value according to what that key holds. */ private static function sanitize_value( string $key, string $value ): string { if ( in_array( $key, self::URL_KEYS, true ) ) { return esc_url_raw( $value ); } return sanitize_text_field( $value ); } }