ame to read. * @param mixed $fallback Returned when the object exposes the property in neither way, or it is null. * @return mixed */ private static function read_author_prop( object $author, string $prop, $fallback ) { if ( property_exists( $author, $prop ) || method_exists( $author, '__get' ) ) { $value = $author->$prop; if ( null !== $value ) { return $value; } } return $fallback; } /** * Declares the page-variables fields that must be delivered outside the * cacheable HTML when the cache-safe data layer is on (issue #398). * * Tier 1 — the site search term (from the URL) and the referring page * (siteSearchFrom) — is only declared on a search results page (the only place * these two fields are output) because the browser computes it with no request. * * Tier 2/3 — the visitor IP, Cloudflare country and the logged-in-user fields — * is declared independent of the page type and gated only on its own option, * because the session endpoint resolves it for the current request, which has no * page context. Each carries a server resolver (run only on the endpoint) so the * value is delivered client-side, once per session (Tier 2) or when the logged-in * cookie changed (Tier 3), instead of baked into cacheable HTML. * * @param array $fields Visitor-scoped fields declared so far. * @return array */ public function declare_visitor_scoped_fields( array $fields ): array { if ( ! $this->opt( GTM4WP_OPTION_CACHE_SAFE_DATALAYER ) ) { return $fields; } if ( $this->opt( GTM4WP_OPTION_INCLUDE_SEARCHDATA ) && is_search() ) { $fields[] = new VisitorField( 'siteSearchTerm', VisitorField::TIER_CLIENT, 'searchTerm' ); $fields[] = new VisitorField( 'siteSearchFrom', VisitorField::TIER_CLIENT, 'searchReferrer' ); } return $this->declare_server_visitor_fields( $fields ); } /** * Declares the Tier 2 (session) and Tier 3 (logged-in user) fields the session * endpoint delivers client-side. Each field is added only when its own option * is on, and carries a resolver that runs for the current request on the * endpoint; the resolver is the field's identity gate (the user resolvers return * null for an anonymous request, so a logged-out caller never receives user data). * * @param array $fields Visitor-scoped fields declared so far. * @return array */ private function declare_server_visitor_fields( array $fields ): array { $login_gate = VisitorDataModule::LOGIN_GATE_COOKIE; // Tier 2: server-only but constant per session; fetched once per session. if ( $this->opt( GTM4WP_OPTION_INCLUDE_VISITOR_IP ) ) { $fields[] = new VisitorField( 'visitorIP', VisitorField::TIER_SESSION, '', array( $this, 'resolve_visitor_ip' ) ); } if ( $this->opt( GTM4WP_OPTION_INCLUDE_MISCGEOCF ) ) { $fields[] = new VisitorField( 'geoCloudflareCountryCode', VisitorField::TIER_SESSION, '', array( $this, 'resolve_cloudflare_country' ) ); } // Tier 3: logged-in user data; fetched only when the login gate cookie changed. if ( $this->opt( GTM4WP_OPTION_INCLUDE_LOGGEDIN ) ) { $fields[] = new VisitorField( 'visitorLoginState', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_login_state' ), $login_gate ); } if ( $this->opt( GTM4WP_OPTION_INCLUDE_USERROLE ) ) { $fields[] = new VisitorField( 'visitorType', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_type' ), $login_gate ); } if ( $this->opt( GTM4WP_OPTION_INCLUDE_USEREMAIL ) ) { $fields[] = new VisitorField( 'visitorEmail', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_email' ), $login_gate ); $fields[] = new VisitorField( 'visitorEmailHash', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_email_hash' ), $login_gate ); } if ( $this->opt( GTM4WP_OPTION_INCLUDE_USERREGDATE ) ) { $fields[] = new VisitorField( 'visitorRegistrationDate', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_registration_date' ), $login_gate ); } if ( $this->opt( GTM4WP_OPTION_INCLUDE_USERNAME ) ) { $fields[] = new VisitorField( 'visitorUsername', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_username' ), $login_gate ); } if ( $this->opt( GTM4WP_OPTION_INCLUDE_USERID ) ) { $fields[] = new VisitorField( 'visitorId', VisitorField::TIER_ACTION, '', array( $this, 'resolve_visitor_id' ), $login_gate ); } return $fields; } /** * Tier 2 resolver: the validated visitor IP for the current request, or null * when it cannot be determined. VisitorIp::get() already wp_unslash+validates * the value with filter_var( FILTER_VALIDATE_IP ), so it is a plain IP string. * * @return string|null */ public function resolve_visitor_ip(): ?string { $ip = VisitorIp::get( (string) $this->opt( GTM4WP_OPTION_INCLUDE_VISITOR_IP_HEADER ), (string) $this->opt( GTM4WP_OPTION_INCLUDE_VISITOR_IP_PROXIES ) ); return '' === $ip ? null : $ip; } /** * Tier 2 resolver: the Cloudflare country code from the current request header, * or null when absent. The value is wp_unslash+sanitized on the way in; the * endpoint hex-encodes it on the way out, so it is passed raw (RI-4). * * @return string|null */ public function resolve_cloudflare_country(): ?string { if ( ! isset( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ) { return null; } $country = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ); return '' === $country ? null : $country; } /** * Tier 3 resolver: 'logged-in' for an authenticated request, null otherwise. * Returning null for an anonymous request is the identity gate — a logged-out * caller receives no login-state field at all. * * @return string|null */ public function resolve_visitor_login_state(): ?string { return is_user_logged_in() ? 'logged-in' : null; } /** * Tier 3 resolver: the current user's roles as a comma separated list, or null * for an anonymous request. * * @return string|null */ public function resolve_visitor_type(): ?string { if ( ! is_user_logged_in() ) { return null; } return implode( ',', wp_get_current_user()->roles ); } /** * Tier 3 resolver: the current user's email address, or null for an anonymous * request. Passed raw; the endpoint hex-encodes it on output. * * @return string|null */ public function resolve_visitor_email(): ?string { if ( ! is_user_logged_in() ) { return null; } $email = wp_get_current_user()->user_email; return empty( $email ) ? '' : $email; } /** * Tier 3 resolver: the SHA-256 hash of the current user's email address, or * null for an anonymous request. * * @return string|null */ public function resolve_visitor_email_hash(): ?string { if ( ! is_user_logged_in() ) { return null; } $email = wp_get_current_user()->user_email; return empty( $email ) ? '' : hash( 'sha256', $email ); } /** * Tier 3 resolver: the Unix timestamp of the current user's registration date, * or null for an anonymous request. * * @return int|string|null */ public function resolve_visitor_registration_date() { if ( ! is_user_logged_in() ) { return null; } $registered = wp_get_current_user()->user_registered; return empty( $registered ) ? '' : strtotime( $registered ); } /** * Tier 3 resolver: the current user's login name, or null for an anonymous * request. Passed raw; the endpoint hex-encodes it on output. * * @return string|null */ public function resolve_visitor_username(): ?string { if ( ! is_user_logged_in() ) { return null; } $login = wp_get_current_user()->user_login; return empty( $login ) ? '' : $login; } /** * Tier 3 resolver: the current user's id, or null for an anonymous request. * * @return int|null */ public function resolve_visitor_id(): ?int { $user_id = get_current_user_id(); return $user_id > 0 ? $user_id : null; } /** * Parses the post-meta allow-list option into a list of meta keys. * * Accepts one key per line or a comma separated list, in any mix. Shared by * the AdminSchema sanitizer and the frontend read above so that what is * stored is exactly what the reader honours - two copies of the same parsing * rule is a divergence waiting for the next tightening (PA-2, the pattern the * trusted-proxy list already follows). * * @param string $value Raw option value. * @return array De-duplicated meta keys, empty when the option is blank. */ public static function parse_meta_key_list( string $value ): array { $entries = preg_split( '/[\r\n,]+/', $value, -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $entries ) ) { return array(); } $keys = array(); foreach ( $entries as $one_entry ) { $one_key = trim( $one_entry ); if ( '' !== $one_key ) { $keys[] = $one_key; } } return array_values( array_unique( $keys ) ); } /** * Removes serialized values from a post meta value before it reaches the * data layer. * * A serialized string is a plugin's internal storage format by definition - * it reaches the browser as an opaque a:8:{...} blob that no Google Tag * Manager variable can read, while publishing the whole nested structure the * plugin keeps behind that key. Skipping is deliberate rather than * unserializing: no working GTM setup can be consuming such a value today, * and unserializing would run object instantiation over every custom field * on the post for data nobody asked for. * * @param mixed $value Single meta value, or the list of values for a multi-value key. * @return mixed The value with serialized entries removed, or null when nothing usable is left. */ private static function drop_serialized_meta_values( $value ) { if ( is_array( $value ) ) { $kept = array(); foreach ( $value as $one_value ) { if ( ! ( is_string( $one_value ) && is_serialized( $one_value ) ) ) { $kept[] = $one_value; } } return array() === $kept ? null : $kept; } if ( is_string( $value ) && is_serialized( $value ) ) { return null; } return $value; } /** * Builds the pageCategory slug list for a set of category terms. * * With the "include parent categories" option off (the default) this returns * just the immediate category slugs, keeping the 1.x output unchanged. With * it on, each category's ancestor slugs (from get_ancestors(): immediate * parent first, up to the top-level category) are appended after the * category's own slug and the final list is de-duplicated while preserving * order. * * @param array $categories Category term objects, as returned by get_the_category(). * @return array List of category slugs. */ private function build_category_slugs( array $categories ): array { $include_parents = (bool) $this->opt( GTM4WP_OPTION_INCLUDE_PARENTCATEGORIES ); $slugs = array(); foreach ( $categories as $one_cat ) { $slugs[] = $one_cat->slug; if ( $include_parents ) { foreach ( get_ancestors( $one_cat->term_id, 'category' ) as $ancestor_id ) { $ancestor_term = get_term( (int) $ancestor_id, 'category' ); if ( $ancestor_term instanceof \WP_Term ) { $slugs[] = $ancestor_term->slug; } } } } return array_values( array_unique( $slugs ) ); } }